diff --git a/web/components/home/flow-gate-preview.module.css b/web/components/home/flow-gate-preview.module.css
index d4614c38..af42917c 100644
--- a/web/components/home/flow-gate-preview.module.css
+++ b/web/components/home/flow-gate-preview.module.css
@@ -1,73 +1,164 @@
-.panel { height: 100%; color: #dce9f4; font-family: var(--font-sans), sans-serif; }
-.content { height: 100%; display: flex; flex-direction: column; gap: clamp(10px, 1.5vw, 20px); padding: 4px 8px; }
-.nodes { display: flex; align-items: center; padding: 7px 9px 0; }
-.node { display: grid; justify-items: center; gap: 8px; min-width: 58px; }
-.node > span { display: grid; place-items: center; width: 40px; height: 40px; border: 1px solid; border-radius: 50%; transition: color .4s, background .4s, border-color .4s; }
-.node b { font-size: 11px; font-weight: 500; }
-.complete { color: #74b8e2; background: #173449; border-color: #427fa5 !important; }
-.active { color: #cd866a; background: #3b2926; border-color: #c1674b !important; }
-.locked { color: #8092a5; background: #162232; border-color: #354558 !important; }
-.connector { flex: 1; height: 2px; background: #354558; margin: -39px 0 0; position: relative; overflow: hidden; transition: background .6s; }
-.lit { background: #427fa5; }
-.lit::after { content: ''; position: absolute; inset: 0; width: 18px; background: #9fd4f5; animation: travel 1.6s ease-in-out infinite; }
-.agent { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #2b4053; font-size: 12px; }
-.agent > span:first-child { font: 9px monospace; color: #8ea5b8; }
-.agent svg { margin-left: auto; color: #74b8e2; }
-.requirements { display: grid; gap: 4px; }
-.caption { font: 9px/1.5 monospace; letter-spacing: .08em; color: #8ea5b8; margin-bottom: 5px; }
-.row { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px; border: 1px solid transparent; border-radius: 5px; transition: background .4s, border-color .4s; }
-.row code { font-size: 12px; }
-.row > span { display: flex; align-items: center; gap: 5px; font-size: 11px; white-space: nowrap; }
-.good { color: #74b8e2; }
-.pending { color: #cd866a; }
-.missing { background: #cd866a10; border-color: #cd866a38; }
-.status { display: flex; align-items: center; gap: 9px; margin-top: auto; padding: 10px 0 3px; color: #cd866a; font-size: 11px; animation: appear .3s ease-out; }
-.success { color: #74b8e2; }
-@keyframes travel { from { transform: translateX(-20px); } to { transform: translateX(150px); } }
-@keyframes appear { from { opacity: .3; } to { opacity: 1; } }
-@media (max-width: 600px) { .content { gap: 9px; padding: 0; } .row { padding: 7px; } .nodes { padding-top: 2px; } .agent { font-size: 11px; } }
-@media (prefers-reduced-motion: reduce) { .panel *, .panel *::after { animation: none; transition: none; } }
+.graph { --success: var(--primary, #74b8e2); --success-border: color-mix(in srgb, var(--success) 55%, #35424e); --success-bg: color-mix(in srgb, var(--success) 15%, #14212b); position: relative; width: 100%; min-width: 0; color: #d4dde6; font-family: var(--font-sans), sans-serif; }
+.viewport { position: relative; overflow: hidden; zoom: .92; }
+.track { position: relative; will-change: transform; }
+.connections { position: absolute; inset: 0; overflow: visible; }
+.connections > path { fill: none; stroke: #3b4b58; stroke-width: 1.2; }
+.connections .gateStop { stroke: #d2a17e; stroke-width: 2; transition: opacity .3s; }
+.pulse { fill: #d8eaf7; opacity: 0; }
+.nodeGroup { position: absolute; transition: opacity .4s; }
+.nodeGroup[data-state='waiting'] { opacity: .48; }
+.node { position: relative; display: flex; align-items: center; gap: 11px; height: 84px; padding: 13px; border: 1px solid #35424e; border-radius: 9px; background: linear-gradient(155deg, #202b36, #17232e); box-shadow: 0 5px 16px #030c142b, inset 0 1px #ffffff05; transition: border-color .4s, box-shadow .4s; }
+.node::before, .node::after { content: ''; position: absolute; top: 38px; width: 6px; height: 6px; border: 1px solid #576574; background: #14202a; border-radius: 50%; }
+.node::before { left: -4px; }
+.node::after { right: -4px; }
+.nodeGroup:first-of-type .node::before { display: none; }
+.nodeGroup[data-state='active'] .node { border-color: #8b9fae; box-shadow: 0 0 0 3px #a4bfd009, 0 8px 22px #020a1429; }
+.nodeGroup[data-state='done'] .node { border-color: var(--success-border); }
+.icon { display: grid; place-items: center; width: 29px; height: 30px; flex-shrink: 0; color: #dce4e9; }
+.icon > img { object-fit: contain; }
+.claude { color: #d79d80; }
+.github img { opacity: .9; }
+.nodeText { display: grid; gap: 5px; min-width: 0; }
+.nodeText b { font-size: 12px; font-weight: 550; letter-spacing: -.15px; }
+.nodeText small { font-size: 10px; line-height: 1.45; color: #a5b3bf; }
+.stateIcon { position: absolute; right: -5px; top: -5px; display: grid; place-items: center; width: 16px; height: 16px; border-radius: 50%; border: 1px solid var(--success-border); background: var(--success-bg); color: var(--success); opacity: 0; }
+.nodeGroup[data-state='done'] .stateIcon { opacity: 1; }
+.nodeGroup[data-state='active'] .stateIcon { opacity: 1; border-color: #adbecb #425362 #425362; background: #162531; animation: spin 1.2s linear infinite; }
+.nodeGroup[data-state='active'] .stateIcon svg { visibility: hidden; }
+.email, .issue, .diff, .review, .tests, .pullRequest, .message, .gateStatus { margin-top: 13px; font-size: 10px; color: #8fa1af; }
+.email { display: flex; align-items: flex-start; gap: 8px; padding: 0 4px; }
+.email b { display: block; margin-bottom: 8px; color: #adbcc8; font-size: 10px; font-weight: 400; white-space: nowrap; }
+.avatar { display: grid; place-items: center; width: 21px; height: 21px; flex-shrink: 0; background: #38403e; color: #c1bdb0; border-radius: 50%; font-size: 8px; }
+.skeleton { display: block; width: 113px; height: 3px; margin-top: 5px; border-radius: 1px; background: #41515e; }
+.skeleton:last-child { width: 76px; opacity: .55; }
+.diff { padding: 9px 10px; border: 1px solid #34424c80; border-radius: 4px; background: #101e29a3; }
+.diff > div { display: flex; justify-content: space-between; margin-bottom: 10px; font: 9px monospace; }
+.diff > div > span { color: var(--success); }
+.diff i { display: block; height: 5px; width: 78%; margin: 5px 0; background: color-mix(in srgb, var(--success) 25%, transparent); border-left: 2px solid var(--success); }
+.diff i:nth-of-type(1) { background: #79594e64; border-color: #bd8b78; width: 68%; }
+.diff i:nth-of-type(3) { width: 90%; }
+.diff i:nth-of-type(4) { width: 59%; }
+.diff i:nth-of-type(5) { width: 72%; }
+.nodeGroup[data-state='active'] .diff i { animation: reveal 1.8s ease both; }
+.nodeGroup[data-state='active'] .diff i:nth-of-type(3) { animation-delay: .4s; }
+.nodeGroup[data-state='active'] .diff i:nth-of-type(4) { animation-delay: .7s; }
+.nodeGroup[data-state='active'] .diff i:nth-of-type(5) { animation-delay: 1s; }
+.review { display: grid; grid-template-columns: 12px 1fr; gap: 7px; padding: 0 12px; color: var(--success); }
+.review span { height: 3px; align-self: center; background: #566c7280; }
+.review span:last-child { width: 70%; }
+.tests { display: flex; gap: 5px; padding: 0 8px; }
+.tests > span { display: grid; place-items: center; width: 14px; height: 14px; border: 1px solid var(--success-border); border-radius: 3px; color: var(--success); background: var(--success-bg); }
+.nodeGroup[data-state='waiting'] .tests > span { color: transparent; background: transparent; border-color: #455462; }
+.nodeGroup[data-state='active'] .tests > span { animation: testPass 2s both; }
+.gateGroup .node { border-color: #7f6754; background: linear-gradient(155deg, #302e2c, #23272b); }
+.gateGroup .icon { color: #d3ad8c; }
+.gateGroup[data-state='active'] .node { border-color: #d1a17d; box-shadow: 0 0 0 3px #d1a17d0a; }
+.required { display: flex; align-items: center; gap: 5px; position: absolute; top: -28px; left: 1px; color: #bca185; font: 8px monospace; letter-spacing: 1.5px; }
+.gateStatus { display: flex; justify-content: center; gap: 6px; align-items: center; color: #b7a087; }
+.passLabel { display: none; align-items: center; gap: 5px; color: var(--success); }
+.nodeGroup[data-gate-state='passed'] .waitLabel { display: none; }
+.nodeGroup[data-gate-state='passed'] .passLabel { display: flex; }
+.nodeGroup[data-gate-state='passed'] .node { border-color: var(--success-border); }
+.connections .gateStop[data-open='true'] { opacity: 0; }
+.pullRequest { display: flex; gap: 7px; align-items: center; padding: 0 5px; color: var(--success); }
+.prNumber { margin-left: auto; color: #798c9c; font: 9px monospace; }
+.message { border-left: 2px solid var(--success-border); padding: 2px 0 2px 9px; }
+.message b { color: #b0bec9; font-weight: 500; }
+.message > div { display: flex; align-items: center; gap: 5px; margin-top: 8px; font-size: 9px; }
+.message > div > svg { color: var(--success); }
+.footer { position: absolute; z-index: 2; inset: auto 0 0; display: flex; align-items: center; padding: 12px 14px 8px; gap: 16px; background: linear-gradient(transparent, #1d2836ed); opacity: 0; pointer-events: none; transition: opacity .2s ease; }
+.graph:hover .footer, .graph:has(:focus-visible) .footer, .graph:has(.timeline:active) .footer { opacity: 1; pointer-events: auto; }
+.timeline { appearance: none; -webkit-appearance: none; flex: 1; min-width: 0; height: 28px; margin: 0; background: transparent; cursor: pointer; touch-action: none; }
+.timeline::-webkit-slider-runnable-track { height: 3px; border-radius: 3px; background: linear-gradient(to right, #adc4d2 var(--progress, 0%), #485b6970 var(--progress, 0%)); }
+.timeline::-moz-range-track { height: 3px; border-radius: 3px; background: #485b6970; }
+.timeline::-moz-range-progress { height: 3px; border-radius: 3px; background: #adc4d2; }
+.timeline::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; width: 10px; height: 10px; margin-top: -3.5px; border: 0; border-radius: 50%; background: #d7e4ec; box-shadow: 0 0 0 3px #adc4d210; }
+.timeline::-moz-range-thumb { width: 10px; height: 10px; border: 0; border-radius: 50%; background: #d7e4ec; box-shadow: 0 0 0 3px #adc4d210; }
+.timeline:focus-visible { outline: 1px solid #a8bfce; outline-offset: 3px; border-radius: 3px; }
+.controls button { display: flex; align-items: center; justify-content: center; border: 0; padding: 4px 0; background: transparent; color: #8c9eac; cursor: pointer; transition: color .2s; }
+.controls { display: flex; flex-shrink: 0; gap: 4px; }
+.controls button { width: 24px; height: 24px; }
+.controls .speed { width: 28px; font: 500 11px var(--font-sans), sans-serif; font-variant-numeric: tabular-nums; }
+.footer button:hover { color: #fff; }
+.footer button:focus-visible { outline: 1px solid #a8bfce; outline-offset: 4px; border-radius: 2px; }
+.graph[data-running='false'] * { animation-play-state: paused !important; }
+@keyframes spin { to { transform: rotate(360deg); } }
+@keyframes reveal { from { opacity: .15; transform: scaleX(.4); transform-origin: left; } to { opacity: 1; transform: scaleX(1); transform-origin: left; } }
+@keyframes testPass { from { color: transparent; border-color: #55616b; background: transparent; } to { color: var(--success); border-color: var(--success-border); background: var(--success-bg); } }
+@media (max-width: 600px) { .footer { padding-inline: 10px; gap: 10px; } }
+@media (prefers-reduced-motion: reduce) { .graph * { animation: none !important; transition: none !important; } .track { will-change: auto; } .pulse { display: none; } }
-.node small { font-size: 9px; line-height: 1.3; color: #a7bed0; white-space: nowrap; }
+.connections > path[data-loop='true'] { stroke: #8d7966; stroke-dasharray: 4 5; opacity: .22; transition: opacity .3s; }
+.connections > path[data-traversing='true'] { stroke: #8fa9bb; }
+.connections > path[data-loop='true'][data-traversing='true'] { opacity: .9; stroke: #c5a280; }
+.stateIcon svg:last-child { display: none; }
+.nodeGroup[data-state='failed'] .node { border-color: #bd8b71; }
+.nodeGroup[data-state='failed'] .stateIcon { opacity: 1; border-color: #bd8b71; color: #e0b298; background: #45352e; }
+.nodeGroup[data-state='failed'] .stateIcon svg:first-child { display: none; }
+.nodeGroup[data-state='failed'] .stateIcon svg:last-child { display: block; }
+.nodeGroup[data-state='failed'] .review { color: #d2a184; }
+.nodeGroup[data-state='failed'] .tests > span:last-child { background: #653c32; border-color: #c39174; color: #e1b69d; }
+.waitLabel { display: flex; gap: 5px; align-items: center; }
+.feedback { margin-top: 12px; padding: 10px; border: 1px solid #8a725447; border-radius: 6px; background: #252b30; font-size: 10px; color: #d8c4b0; }
+.feedback > div { display: flex; align-items: center; gap: 6px; }
+.feedback > div > img:first-child { border-radius: 50%; }
+.feedback b { font-weight: 500; }
+.feedback p { margin: 7px 0 0; line-height: 1.5; }
+.feedbackAnswer { display: flex; align-items: flex-start; gap: 6px; margin-top: 9px; padding-top: 9px; border-top: 1px solid #48545a; color: var(--success); line-height: 1.5; }
+.feedbackAnswer svg { flex-shrink: 0; margin-top: 1px; }
+.classification { display: flex; gap: 7px; align-items: center; margin: 13px 4px 0; color: #93a9b7; font: 9px monospace; }
+.research { display: grid; gap: 6px; margin: 12px 8px 0; }
+.research > div { display: grid; grid-template-columns: 1fr repeat(3, 23px); align-items: center; gap: 8px; color: #91a5b4; font: 9px monospace; }
+.research i { height: 4px; background: #566472; opacity: .3; }
+.research i[data-filled='true'] { background: var(--success); opacity: .8; }
+.retention { margin: 14px 8px 0; font-size: 9px; color: #ad9b86; }
+.retention > div { display: flex; gap: 4px; margin-top: 7px; }
+.retention i { width: 13px; height: 7px; background: #6b736e30; }
+.retention i[data-filled='true'] { background: #b18a6366; }
+.draft { display: flex; gap: 9px; margin: 13px 10px 0; color: #97adbe; }
+.draft .skeleton { width: 155px; }
+.draft .skeleton:last-child { width: 98px; }
+.nodeGroup[data-state='active'] .draft .skeleton { animation: reveal 2s both; }
+.nodeGroup[data-state='active'] .draft .skeleton:nth-child(2) { animation-delay: .4s; }
+.nodeGroup[data-state='active'] .draft .skeleton:nth-child(3) { animation-delay: .8s; }
-/* Fixed tracks keep every stage in place throughout the animation. */
-.panel { width: 100%; min-width: 0; }
-.content { width: 100%; box-sizing: border-box; min-width: 0; gap: 14px; }
-.nodes { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 4px 0 0; flex: 0 0 82px; }
-.node { position: relative; min-width: 0; grid-template-rows: 40px 14px 12px; gap: 4px; align-content: start; }
-.node > span { position: relative; z-index: 1; box-sizing: border-box; }
-.node:not(:last-child)::after { content: ''; position: absolute; height: 2px; top: 19px; left: calc(50% + 20px); width: calc(100% - 40px); background: #354558; transition: background .4s; }
-.node.done::after { background: #427fa5; }
-.node small { font-size: 9px; }
-.agent { display: grid; grid-template-columns: 44px minmax(0, 1fr) 14px; box-sizing: border-box; height: 45px; flex: 0 0 45px; gap: 6px; padding: 5px 0; font-size: 11px; }
-.agent > span:nth-child(2) { min-width: 0; }
-.row { box-sizing: border-box; height: 37px; }
-.status { min-height: 32px; flex-shrink: 0; box-sizing: border-box; }
-.status svg { flex-shrink: 0; }
-@media (max-width: 600px) { .content { gap: 7px; } .nodes { flex-basis: 77px; } .node b { font-size: 10px; } .agent { font-size: 10px; height: 38px; flex-basis: 38px; } .row { height: 33px; } }
+.humanGroup .icon { width: 44px; height: 44px; }
+.person { position: relative; display: block; width: 44px; height: 44px; }
+.portrait { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; object-position: 50% 30%; border: 1px solid #e0c3a850; }
+.slackBadge { position: absolute; right: -5px; bottom: -3px; display: grid; place-items: center; width: 22px; height: 22px; background: #25313a; border: 2px solid #303234; border-radius: 7px; }
+.humanGroup .node { border-color: #a48569; background: linear-gradient(145deg, #37322f, #282c30); }
+.humanGroup .nodeText small { color: #d1bba6; }
+.humanGroup[data-state='active'] .stateIcon { animation: none; border-color: #c19f7f; background: #46372c; }
+.humanGroup[data-state='active'] .stateIcon::after { content: ''; width: 4px; height: 4px; border-radius: 50%; background: #dfb989; }
+.humanGroup[data-state='active'] .stateIcon svg { display: none; }
+.humanStatus .passLabel { padding: 5px 9px; border-radius: 4px; background: color-mix(in srgb, var(--success) 8%, transparent); }
+.crm { display: grid; gap: 8px; margin-top: 13px; padding: 10px; border: 1px solid #35424e; border-radius: 4px; font-size: 10px; color: #8fa1af; }
+.crm b { color: #bccbd5; font-weight: 500; }
+.crm > span { display: flex; justify-content: space-between; }
+.crm i { color: var(--success); font-style: normal; }
+.codingTerminal { --term-accent: #b6cee0; display: flex; flex-direction: column; margin-top: 12px; padding: 8px 10px; border: 1px solid #35424e; border-radius: 5px; background: #14212bcc; font: 9px/1.5 var(--font-geist-mono), monospace; }
+.codingTerminal[data-agent='claude'] { --term-accent: #d79d80; }
+.terminalOutput { order: 2; height: 41px; overflow: hidden; color: #a3b4c0; }
+.terminalOutput > span { display: block; white-space: nowrap; }
+.terminalResult { color: var(--success); }
+.terminalWorking, .terminalFinished { order: 1; height: 14px; align-items: center; gap: 6px; margin-bottom: 5px; }
+.terminalWorking { display: none; color: var(--term-accent); }
+.terminalWorking b { font-weight: 500; }
+.terminalWorking > span:last-child { margin-left: auto; width: 4px; height: 9px; animation-duration: calc(920ms / var(--work-speed, 1)); }
+.terminalWorking > span:first-child { animation-duration: calc(1.7s / var(--work-speed, 1)); }
+.codingTerminal[data-agent='codex'] .terminalWorking > span:first-child { animation-duration: calc(1.25s / var(--work-speed, 1)); }
+.terminalFinished { display: flex; color: var(--success); visibility: hidden; }
+.nodeGroup[data-state='active'] .terminalWorking { display: flex; }
+.nodeGroup[data-state='active'] .terminalFinished { display: none; }
+.nodeGroup[data-state='done'] .terminalFinished { visibility: visible; }
+.nodeGroup[data-state='active'] .terminalOutput > span { animation: terminalWrite calc(3.2s / var(--work-speed, 1)) infinite both; }
+.nodeGroup[data-state='active'] .terminalOutput > span:nth-child(2) { animation-delay: calc(.45s / var(--work-speed, 1)); }
+.nodeGroup[data-state='active'] .terminalOutput > span:nth-child(3) { animation-delay: calc(.9s / var(--work-speed, 1)); }
+.nodeGroup[data-state='active'] .codingTerminal[data-agent='codex'] .terminalOutput > span { animation-duration: calc(3.7s / var(--work-speed, 1)); }
+.graph[data-running='false'] .terminalOutput > span { clip-path: inset(0) !important; opacity: 1 !important; }
+@keyframes terminalWrite { 0%, 8% { opacity: .25; clip-path: inset(0 100% 0 0); } 35%, 90% { opacity: 1; clip-path: inset(0); } 100% { opacity: .25; clip-path: inset(0); } }
+@media (prefers-reduced-motion: reduce) { .nodeGroup .codingTerminal * { animation: none !important; } }
-.branchNodes { position: relative; width: 100%; height: 156px; flex: 0 0 156px; }
-.branchLines { position: absolute; inset: 0; width: 100%; height: 156px; overflow: visible; }
-.branchLines path { fill: none; stroke: #354558; stroke-width: 1.5; vector-effect: non-scaling-stroke; transition: stroke .4s; }
-.branchLines .pathDone { stroke: #427fa5; }
-.branchNode { position: absolute; width: 20%; transform: translateX(-50%); }
-.branchNode::after { display: none; }
-.content { gap: 8px; }
-@media (max-width: 600px) { .content { gap: 3px; } .branchNodes { margin-bottom: -8px; } .agent { height: 30px; flex-basis: 30px; } .row { height: 28px; padding-top: 3px; padding-bottom: 3px; } .status { padding-top: 3px; font-size: 10px; } }
-
-.branchNodes { height: 168px; flex-basis: 168px; width: calc(100% - 16px); }
-.branchLines { height: 168px; }
-.branchNode { width: 16%; grid-template-rows: 36px 14px 26px; gap: 3px; }
-.branchNode > span { width: 36px; height: 36px; }
-.branchNode b { font-size: 10px; }
-.branchNode small { white-space: normal; text-align: center; font-size: 8px; line-height: 1.3; width: 100%; }
-.agent { font-size: 10px; }
-@media (max-width: 600px) { .branchNode b { font-size: 9px; } .branchNode small { font-size: 7px; } .branchNode > span { width: 30px; height: 30px; margin-top: 3px; } }
-
-@media (max-width: 680px) {
- .content { justify-content: center; }
- .branchNodes { margin-bottom: 0; }
- .agent,
- .requirements,
- .status { display: none; }
+@media (hover: none), (pointer: coarse) {
+ .footer { opacity: 1; pointer-events: auto; }
}
diff --git a/web/lib/feature-request-demo.ts b/web/lib/feature-request-demo.ts
new file mode 100644
index 00000000..47dd6484
--- /dev/null
+++ b/web/lib/feature-request-demo.ts
@@ -0,0 +1,170 @@
+export const NODE_WIDTH = 220;
+export const NODE_HEIGHT = 84;
+export const CANVAS_WIDTH = 3994;
+export const CANVAS_HEIGHT = 420;
+
+export const workflowNodes = [
+ { id: 'request', name: 'Gmail', action: 'Feature request', x: 40, y: 136, icon: 'gmail', detail: 'email' },
+ { id: 'classify', name: 'Script', action: 'Classify', x: 332, y: 136, icon: 'script', detail: 'classification' },
+ { id: 'competitors', name: 'Grok', action: 'Research competitor functionality', x: 640, y: 40, icon: 'grok', detail: 'research' },
+ { id: 'retention', name: 'Claude', action: 'Evaluate customer retention risk', x: 640, y: 222, icon: 'claude', detail: 'retention' },
+ { id: 'priority', name: 'Codex', action: 'Determine priority', x: 948, y: 136, icon: 'codex', detail: 'priority', gate: true },
+ { id: 'will-approval', name: 'Will Washburn', action: 'Get approval from Will', x: 1256, y: 136, icon: 'will', detail: 'human', gate: true, photo: '/authors/will.png' },
+ { id: 'frontend', name: 'Claude Code', action: 'Implement SSO settings', x: 1564, y: 40, icon: 'claude-code', detail: 'frontend' },
+ { id: 'backend', name: 'Codex', action: 'Implement SAML auth', x: 1564, y: 222, icon: 'codex', detail: 'backend' },
+ { id: 'frontend-review', name: 'Codex', action: 'Review UI + accessibility', x: 1872, y: 40, icon: 'codex', detail: 'review' },
+ { id: 'backend-review', name: 'Claude Code', action: 'Review auth + security', x: 1872, y: 222, icon: 'claude-code', detail: 'review' },
+ { id: 'tests', name: 'Script', action: 'Integration tests', x: 2180, y: 136, icon: 'script', detail: 'tests', gate: true },
+ { id: 'feature', name: 'GitHub', action: 'Open reviewed pull request', x: 2488, y: 136, icon: 'github', detail: 'pull-request' },
+ { id: 'khaliq-approval', name: 'Khaliq Gant', action: 'Get approval from Khaliq', x: 2796, y: 136, icon: 'khaliq', detail: 'human', gate: true, photo: '/authors/khaliq.jpeg' },
+ { id: 'crm-agent', name: 'Claude', action: 'Update CRM with summary', x: 3104, y: 40, icon: 'claude', detail: 'crm-draft' },
+ { id: 'email-agent', name: 'Claude', action: 'Write customer email', x: 3104, y: 222, icon: 'claude', detail: 'draft' },
+ { id: 'crm-updated', name: 'HubSpot', action: 'Update customer record', x: 3412, y: 40, icon: 'hubspot', detail: 'crm' },
+ { id: 'email-sent', name: 'Gmail', action: 'Send feature update', x: 3412, y: 222, icon: 'gmail', detail: 'sent' },
+ { id: 'slack-sent', name: 'Slack', action: 'Notify #engineering', x: 3720, y: 136, icon: 'slack', detail: 'slack' },
+] as const;
+export type NodeId = typeof workflowNodes[number]['id'];
+export type WorkflowNode = typeof workflowNodes[number];
+const nodeById = Object.fromEntries(workflowNodes.map((node) => [node.id, node])) as Record
;
+
+function forward(id: string, from: NodeId, to: NodeId) {
+ const a = nodeById[from];
+ const b = nodeById[to];
+ const x1 = a.x + NODE_WIDTH;
+ const y1 = a.y + NODE_HEIGHT / 2;
+ // Work stops immediately outside gates, before entering the gated node.
+ const x2 = b.x - ('gate' in b ? 16 : 0);
+ const y2 = b.y + NODE_HEIGHT / 2;
+ const mid = (x1 + x2) / 2;
+ return { id, from, to, loop: false, d: `M${x1} ${y1} C${mid} ${y1} ${mid} ${y2} ${x2} ${y2}` };
+}
+function loop(id: string, from: NodeId, to: NodeId, bottom = false) {
+ const a = nodeById[from];
+ const b = nodeById[to];
+ const x1 = a.x + NODE_WIDTH / 2;
+ const x2 = b.x + NODE_WIDTH / 2;
+ const y1 = a.y + (bottom ? NODE_HEIGHT : 0);
+ const y2 = b.y + (bottom ? NODE_HEIGHT : 0);
+ const rail = bottom ? CANVAS_HEIGHT - 12 : 12;
+ const bend = bottom ? -18 : 18;
+ return { id, from, to, loop: true, d: `M${x1} ${y1} V${rail + bend} Q${x1} ${rail} ${x1 - 18} ${rail} H${x2 + 18} Q${x2} ${rail} ${x2} ${rail + bend} V${y2}` };
+}
+export const workflowEdges = [
+ forward('classify', 'request', 'classify'),
+ forward('research', 'classify', 'competitors'),
+ forward('risk', 'classify', 'retention'),
+ forward('research-evidence', 'competitors', 'priority'),
+ forward('risk-evidence', 'retention', 'priority'),
+ loop('deepen-research', 'priority', 'competitors'),
+ loop('deepen-risk', 'priority', 'retention', true),
+ forward('request-will', 'priority', 'will-approval'),
+ loop('will-revision', 'will-approval', 'priority'),
+ forward('build-ui', 'will-approval', 'frontend'),
+ forward('build-auth', 'will-approval', 'backend'),
+ forward('review-ui', 'frontend', 'frontend-review'),
+ forward('review-auth', 'backend', 'backend-review'),
+ loop('revise-ui', 'frontend-review', 'frontend'),
+ loop('revise-auth', 'backend-review', 'backend', true),
+ forward('test-ui', 'frontend-review', 'tests'),
+ forward('test-auth', 'backend-review', 'tests'),
+ loop('fix-test', 'tests', 'backend', true),
+ forward('feature-ready', 'tests', 'feature'),
+ forward('request-khaliq', 'feature', 'khaliq-approval'),
+ forward('draft-crm', 'khaliq-approval', 'crm-agent'),
+ forward('draft-email', 'khaliq-approval', 'email-agent'),
+ forward('update-crm', 'crm-agent', 'crm-updated'),
+ forward('send-email', 'email-agent', 'email-sent'),
+ forward('crm-ready', 'crm-updated', 'slack-sent'),
+ forward('email-ready', 'email-sent', 'slack-sent'),
+];
+
+interface Phase {
+ name: string;
+ duration: number;
+ active: NodeId[];
+ done: NodeId[];
+ routes: string[];
+ focus: NodeId;
+ revision?: number;
+ failed?: NodeId[];
+ feedback?: { node: NodeId; question: string; answer?: string };
+}
+const through = (last: NodeId): NodeId[] => workflowNodes.slice(0, workflowNodes.findIndex((node) => node.id === last) + 1).map((node) => node.id);
+const research: NodeId[] = ['competitors', 'retention'];
+const implementers: NodeId[] = ['frontend', 'backend'];
+const reviewers: NodeId[] = ['frontend-review', 'backend-review'];
+const phases: Phase[] = [
+ { name: 'intake', duration: 1800, active: ['request'], done: [], routes: [], focus: 'request' },
+ { name: 'classify', duration: 2000, active: ['classify'], done: through('request'), routes: ['classify'], focus: 'classify' },
+ { name: 'research', duration: 3800, active: research, done: through('classify'), routes: ['research', 'risk'], focus: 'competitors' },
+ { name: 'priority-check', duration: 2200, active: ['priority'], done: through('retention'), routes: ['research-evidence', 'risk-evidence'], focus: 'priority' },
+ { name: 'more-evidence', duration: 3000, active: research, done: through('classify'), routes: ['deepen-research', 'deepen-risk'], focus: 'competitors', revision: 1 },
+ { name: 'priority-recheck', duration: 2200, active: ['priority'], done: through('retention'), routes: ['research-evidence', 'risk-evidence'], focus: 'priority' },
+ { name: 'priority-passed', duration: 1300, active: [], done: through('priority'), routes: [], focus: 'priority' },
+ { name: 'will-approval', duration: 3800, active: ['will-approval'], done: through('priority'), routes: ['request-will'], focus: 'will-approval' },
+ { name: 'will-question', duration: 2600, active: ['will-approval'], done: through('priority'), routes: [], focus: 'will-approval', feedback: { node: 'will-approval', question: 'Can we ship before their renewal?' } },
+ { name: 'revise-priority', duration: 3600, active: ['priority'], done: through('retention'), routes: ['will-revision'], focus: 'priority', revision: 2, feedback: { node: 'priority', question: 'Can we ship before their renewal?', answer: 'SSO first. Ship before renewal.' } },
+ { name: 'priority-updated', duration: 1200, active: [], done: through('priority'), routes: [], focus: 'priority' },
+ { name: 'will-reapproval', duration: 2600, active: ['will-approval'], done: through('priority'), routes: ['request-will'], focus: 'will-approval', feedback: { node: 'will-approval', question: 'Can we ship before their renewal?', answer: 'SSO first. Ship before renewal.' } },
+ { name: 'will-approved', duration: 1500, active: [], done: through('will-approval'), routes: [], focus: 'will-approval' },
+ { name: 'implement', duration: 2200, active: implementers, done: through('will-approval'), routes: ['build-ui', 'build-auth'], focus: 'frontend' },
+ { name: 'ui-ready', duration: 1800, active: ['backend', 'frontend-review'], done: [...through('will-approval'), 'frontend'], routes: ['review-ui'], focus: 'frontend' },
+ { name: 'review', duration: 2000, active: reviewers, done: through('backend'), routes: ['review-auth'], focus: 'frontend-review' },
+ { name: 'auth-review-ready', duration: 1400, active: ['frontend-review'], done: [...through('backend'), 'backend-review'], routes: [], focus: 'frontend-review' },
+ { name: 'changes-requested', duration: 1300, active: [], done: through('backend'), routes: [], focus: 'frontend-review', failed: reviewers },
+ { name: 'revise', duration: 2100, active: implementers, done: through('will-approval'), routes: ['revise-ui', 'revise-auth'], focus: 'frontend', revision: 1 },
+ { name: 'auth-revision-ready', duration: 1700, active: ['frontend', 'backend-review'], done: [...through('will-approval'), 'backend'], routes: ['review-auth'], focus: 'frontend', revision: 1 },
+ { name: 'review-again', duration: 1900, active: reviewers, done: through('backend'), routes: ['review-ui'], focus: 'frontend-review', revision: 1 },
+ { name: 'ui-review-ready', duration: 1200, active: ['backend-review'], done: [...through('backend'), 'frontend-review'], routes: [], focus: 'frontend-review', revision: 1 },
+ { name: 'revise-again', duration: 2000, active: implementers, done: through('will-approval'), routes: ['revise-ui', 'revise-auth'], focus: 'frontend', revision: 2 },
+ { name: 'ui-final-ready', duration: 1500, active: ['backend', 'frontend-review'], done: [...through('will-approval'), 'frontend'], routes: ['review-ui'], focus: 'frontend', revision: 2 },
+ { name: 'final-review', duration: 1800, active: reviewers, done: through('backend'), routes: ['review-auth'], focus: 'frontend-review', revision: 2 },
+ { name: 'auth-final-review-ready', duration: 1600, active: ['frontend-review'], done: [...through('backend'), 'backend-review'], routes: [], focus: 'frontend-review', revision: 2 },
+ { name: 'test', duration: 2300, active: ['tests'], done: through('backend-review'), routes: ['test-ui', 'test-auth'], focus: 'tests' },
+ { name: 'test-failed', duration: 1200, active: [], done: through('backend-review'), routes: [], focus: 'tests', failed: ['tests'] },
+ { name: 'fix-test', duration: 3000, active: ['backend'], done: [...through('will-approval'), 'frontend', 'frontend-review'], routes: ['fix-test'], focus: 'backend', revision: 3 },
+ { name: 'review-fix', duration: 2400, active: ['backend-review'], done: [...through('backend'), 'frontend-review'], routes: ['review-auth'], focus: 'backend-review', revision: 3 },
+ { name: 'retest', duration: 2400, active: ['tests'], done: through('backend-review'), routes: ['test-ui', 'test-auth'], focus: 'tests' },
+ { name: 'tests-passed', duration: 1300, active: [], done: through('tests'), routes: [], focus: 'tests' },
+ { name: 'feature-ready', duration: 2200, active: ['feature'], done: through('tests'), routes: ['feature-ready'], focus: 'feature' },
+ { name: 'khaliq-approval', duration: 3800, active: ['khaliq-approval'], done: through('feature'), routes: ['request-khaliq'], focus: 'khaliq-approval' },
+ { name: 'khaliq-approved', duration: 1500, active: [], done: through('khaliq-approval'), routes: [], focus: 'khaliq-approval' },
+ { name: 'notify', duration: 3200, active: ['crm-agent', 'email-agent'], done: through('khaliq-approval'), routes: ['draft-crm', 'draft-email'], focus: 'crm-agent' },
+ { name: 'update-records', duration: 2200, active: ['crm-updated', 'email-sent'], done: through('email-agent'), routes: ['update-crm', 'send-email'], focus: 'crm-updated' },
+ { name: 'waiting-for-email', duration: 2600, active: ['email-sent'], done: through('crm-updated'), routes: [], focus: 'email-sent' },
+ { name: 'send-slack', duration: 2000, active: ['slack-sent'], done: through('email-sent'), routes: ['crm-ready', 'email-ready'], focus: 'slack-sent' },
+ { name: 'complete', duration: 2400, active: [], done: through('slack-sent'), routes: [], focus: 'slack-sent' },
+];
+export const workflowPhases = phases.map((phase, index) => ({
+ ...phase,
+ priorityPassed: index >= phases.findIndex((item) => item.name === 'priority-passed') && phase.name !== 'revise-priority',
+ testsPassed: index >= phases.findIndex((item) => item.name === 'tests-passed'),
+ willApproved: index >= phases.findIndex((item) => item.name === 'will-approved'),
+ updatesPassed: phase.done.includes('crm-updated') && phase.done.includes('email-sent'),
+ khaliqApproved: index >= phases.findIndex((item) => item.name === 'khaliq-approved'),
+}));
+export const workflowChapters = [
+ { label: 'Intake', phase: 'intake' },
+ { label: 'Research', phase: 'research' },
+ { label: 'Priority', phase: 'priority-check' },
+ { label: 'Will', phase: 'will-approval' },
+ { label: 'Build', phase: 'implement' },
+ { label: 'Review', phase: 'review' },
+ { label: 'Khaliq', phase: 'khaliq-approval' },
+ { label: 'Notify', phase: 'notify' },
+].map((chapter) => ({ ...chapter, index: phases.findIndex((phase) => phase.name === chapter.phase) }));
+export const workflowDuration = phases.reduce((sum, phase) => sum + phase.duration, 0);
+
+export function workflowCameraTarget(index: number, width: number) {
+ const node = nodeById[workflowPhases[index].focus];
+ const focus = node.x + NODE_WIDTH / 2;
+ return Math.max(0, Math.min(CANVAS_WIDTH - width, focus - width * .48));
+}
+
+export function workflowGatePassed(id: NodeId, phase: typeof workflowPhases[number]) {
+ if (id === 'priority') return phase.priorityPassed;
+ if (id === 'tests') return phase.testsPassed;
+ if (id === 'will-approval') return phase.willApproved;
+ if (id === 'khaliq-approval') return phase.khaliqApproved;
+ return false;
+}
diff --git a/web/lib/flow-agent-settings.ts b/web/lib/flow-agent-settings.ts
new file mode 100644
index 00000000..9d56c5c0
--- /dev/null
+++ b/web/lib/flow-agent-settings.ts
@@ -0,0 +1,51 @@
+import { isCodingAgent, type CodingAgent } from './flow-agents';
+import type { WorkflowId, WorkflowStep } from './flow-workflows';
+
+export const AGENT_ROLES = ['planner', 'plan-reviewer', 'prototype-1', 'prototype-2', 'prototype-3', 'comparator', 'implementer', 'adversary', 'fixer'] as const;
+export type AgentRole = typeof AGENT_ROLES[number];
+export type AgentSettings = { agent?: CodingAgent; model?: string; prompt?: string };
+export type FlowAgentSettings = Partial>;
+
+export function rolesForStep(step: WorkflowStep): AgentRole[] {
+ if (step === '3 implementations') return ['prototype-1', 'prototype-2', 'prototype-3'];
+ if (step === '2× adversarial review') return ['adversary', 'fixer'];
+ return ({ Plan: ['planner'], 'Review plan': ['plan-reviewer'], Compare: ['comparator'], Implement: ['implementer'], Build: ['implementer'], Review: ['adversary'] } as Partial>)[step] ?? [];
+}
+
+export function defaultAgentPrompt(workflow: WorkflowId, role: AgentRole): string {
+ if (role.startsWith('prototype-')) return 'Implement independently using the assigned approach. Work only in this worktree. Add and run tests. Commit your implementation and write prototype-notes.md with results and tradeoffs. Do not open a PR.';
+ switch (role) {
+ case 'planner': return 'Read the repository and write plan.md. Do not implement yet.';
+ case 'plan-reviewer': return 'Review plan.md against the ticket and repository. Challenge assumptions, address gaps, and write reviewed-plan.md. Do not implement yet.';
+ case 'comparator': return 'Compare the implementations and test results in the provided worktrees. Read their code and prototype-notes.md. Write comparison.md with each prototype path, strengths, weaknesses, and which ideas to combine. Do not modify the prototypes or implement yet.';
+ case 'implementer': return (workflow === 'traditional' ? 'Follow reviewed-plan.md. ' : workflow === 'prototype' ? 'Read comparison.md and inspect the prototype implementations it references. Combine the strongest ideas into the final implementation on the current branch, not in the prototype worktrees. ' : '') + 'Implement on the current branch. Add regression tests. Commit changes. Write a PR summary to summary.md.';
+ case 'adversary': return 'Review the PR diff, tests, and all PR comments. ' + (workflow === 'prototype' ? 'Read comparison.md to check that the final implementation combines the strongest ideas. ' : '') + 'Find bugs and edge cases. Write review.md. Create review.clean only if no issues remain.';
+ case 'fixer': return 'Read review.md and gh pr view --comments. Address every issue. Commit fixes without pushing. The workflow runs tests and pushes only after they pass.';
+ default: return '';
+ }
+}
+
+export function resolveAgentSettings(workflow: WorkflowId, role: AgentRole, selected: readonly string[], settings: FlowAgentSettings = {}) {
+ const available = [...new Set(selected.filter(isCodingAgent))];
+ const builder = available[0] ?? 'claude';
+ const reviewer = available.find(id => id !== builder) ?? builder;
+ const defaultAgent = ['plan-reviewer', 'comparator', 'adversary', 'prototype-2'].includes(role) ? reviewer : builder;
+ const saved = settings[`${workflow}:${role}`];
+ // Changing the selected agents must never leave an unavailable CLI assigned.
+ const agent = saved?.agent && available.includes(saved.agent) ? saved.agent : defaultAgent;
+ const compatible = !saved?.agent || saved.agent === agent;
+ return { agent, model: compatible ? saved?.model?.trim() || '' : '', prompt: saved?.prompt ?? defaultAgentPrompt(workflow, role) };
+}
+
+export function validFlowAgentSettings(value: unknown): value is FlowAgentSettings {
+ if (value === undefined) return true;
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ return Object.entries(value).every(([key, settings]) => {
+ const [workflow, role, extra] = key.split(':');
+ if (extra || !['traditional', 'prototype', 'simple'].includes(workflow) || !(AGENT_ROLES as readonly string[]).includes(role)) return false;
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return false;
+ return Object.entries(settings).every(([field, v]) => field === 'agent' ? typeof v === 'string' && isCodingAgent(v)
+ : field === 'model' ? typeof v === 'string' && v.length <= 120 && !/[\r\n\0]/.test(v)
+ : field === 'prompt' ? typeof v === 'string' && v.trim().length > 0 && v.length <= 6000 : false);
+ });
+}
diff --git a/web/lib/flow-agents.ts b/web/lib/flow-agents.ts
new file mode 100644
index 00000000..de84c5ac
--- /dev/null
+++ b/web/lib/flow-agents.ts
@@ -0,0 +1,18 @@
+export const CODING_AGENTS = [
+ { id: 'claude', label: 'Claude Code', available: true },
+ { id: 'codex', label: 'Codex', available: true },
+ { id: 'gemini', label: 'Gemini CLI', available: false },
+ { id: 'opencode', label: 'OpenCode', available: true },
+ { id: 'cursor', label: 'Cursor', available: true },
+ { id: 'copilot', label: 'GitHub Copilot', available: false },
+ { id: 'windsurf', label: 'Windsurf', available: false },
+ { id: 'aider', label: 'Aider', available: false },
+ { id: 'goose', label: 'Goose', available: false },
+ { id: 'grok', label: 'Grok', available: false },
+ { id: 'pi', label: 'Pi', available: false },
+] as const;
+export type AgentId = (typeof CODING_AGENTS)[number]['id'];
+export type CodingAgent = Extract<(typeof CODING_AGENTS)[number], { available: true }>['id'];
+export function isCodingAgent(id: string): id is CodingAgent {
+ return CODING_AGENTS.some(agent => agent.id === id && agent.available);
+}
diff --git a/web/lib/flow-analytics.ts b/web/lib/flow-analytics.ts
new file mode 100644
index 00000000..1072d964
--- /dev/null
+++ b/web/lib/flow-analytics.ts
@@ -0,0 +1,117 @@
+import { CODING_AGENTS, isCodingAgent, otherAgentIsSelected, type FactoryDraft } from './flow-onboarding';
+
+export const FLOW_ANALYTICS_VERSION = 2;
+export const FLOW_STAGES = ['intro', 'sources', 'agents', 'task', 'connections'] as const;
+export type FlowStage = typeof FLOW_STAGES[number];
+export type FlowTrack = (event: string, properties?: Record) => void;
+export const JOURNEY_TIMEOUT_MS = 30 * 60_000;
+export const JOURNEY_STORAGE_KEY = 'agentrelay:flows:analytics:v2';
+
+export function flowMetrics(draft: FactoryDraft) {
+ return {
+ sources: [...draft.sources], source_count: draft.sources.length,
+ agents: draft.agents.filter(id => CODING_AGENTS.some(agent => agent.id === id)),
+ supported_agent_count: draft.agents.filter(isCodingAgent).length,
+ requested_agent_count: draft.agents.filter(id => !isCodingAgent(id)).length,
+ other_agent_selected: otherAgentIsSelected(draft), workflow: draft.workflow,
+ has_extra_instructions: Boolean(draft.task.trim()),
+ configured_filters: draft.sources.flatMap(id => Object.entries(draft.sourceSettings[id] ?? {})
+ .filter(([, value]) => value === true || (typeof value === 'string' && value.trim()))
+ .map(([field]) => `${id}.${field}`)),
+ };
+}
+export function lengthBucket(length: number): string {
+ return length === 0 ? 'empty' : length <= 20 ? '1-20' : length <= 100 ? '21-100' : '101+';
+}
+export function journeyId(storage: Pick, createId: () => string, now = Date.now()) {
+ try {
+ const saved = JSON.parse(storage.getItem(JOURNEY_STORAGE_KEY) ?? 'null');
+ if (/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(saved?.id) && Number.isFinite(saved.updatedAt) && now >= saved.updatedAt && now - saved.updatedAt < JOURNEY_TIMEOUT_MS) {
+ storage.setItem(JOURNEY_STORAGE_KEY, JSON.stringify({ id: saved.id, updatedAt: now }));
+ return saved.id as string;
+ }
+ } catch { /* Storage is optional. */ }
+ const id = createId();
+ try { storage.setItem(JOURNEY_STORAGE_KEY, JSON.stringify({ id, updatedAt: now })); } catch { /* Optional. */ }
+ return id;
+}
+
+export type AnalyticsSink = (event: string, properties: Record, beacon?: boolean) => void;
+/** No DOM text, input values, code, URLs, or exception messages enter this tracker. */
+export class FlowJourneyTracker {
+ private current: { stage: FlowStage; visit: number; started: number; activeSince: number | null; activeMs: number; changes: number; left: boolean } | null = null;
+ private visits = new Map();
+ private outcome = 'none';
+ constructor(private sink: AnalyticsSink, readonly id: string, private now = Date.now, private metrics: () => Record = () => ({})) {}
+ view(stage: FlowStage) {
+ if (this.current?.stage === stage && !this.current.left) return;
+ this.leave('route_change');
+ const visit = (this.visits.get(stage) ?? 0) + 1;
+ this.visits.set(stage, visit);
+ this.current = { stage, visit, started: this.now(), activeSince: this.now(), activeMs: 0, changes: 0, left: false };
+ this.outcome = 'none';
+ this.track('step_viewed', { is_revisit: visit > 1 });
+ }
+ track(event: string, properties: Record = {}, beacon = false) {
+ const state = this.current;
+ if (!state) return;
+ if (event === 'choice_changed') state.changes++;
+ const now = this.now();
+ try { this.sink(`flows_onboarding_${event}`, {
+ ...this.metrics(), ...properties, funnel_version: FLOW_ANALYTICS_VERSION, journey_id: this.id,
+ stage: state.stage, stage_index: FLOW_STAGES.indexOf(state.stage), visit_number: state.visit,
+ elapsed_ms: Math.max(0, now - state.started),
+ active_ms: state.activeMs + (state.activeSince === null ? 0 : Math.max(0, now - state.activeSince)),
+ choice_changes: state.changes, last_outcome: this.outcome,
+ }, beacon || event === 'cloud_handoff_started'); } catch { /* Analytics must never interrupt onboarding. */ }
+ }
+ markOutcome(outcome: 'cloud_handoff' | 'local_kit_downloaded') { this.outcome = outcome; }
+ pause() {
+ if (!this.current || this.current.activeSince === null || this.current.left) return;
+ this.current.activeMs += Math.max(0, this.now() - this.current.activeSince);
+ this.current.activeSince = null;
+ this.track('step_paused', {}, true);
+ }
+ resume() {
+ if (!this.current) return;
+ if (this.current.left) { this.view(this.current.stage); return; }
+ if (this.current.activeSince !== null) return;
+ this.current.activeSince = this.now();
+ this.track('step_resumed');
+ }
+ leave(reason: 'route_change' | 'pagehide' | 'unmount') {
+ if (!this.current || this.current.left) return;
+ this.track('step_left', { reason, exit_signal: reason !== 'route_change' }, reason === 'pagehide');
+ this.current.left = true;
+ }
+}
+
+/** Keeps mounted tabs and persisted journeys on the same inactivity boundary. */
+export class FlowJourneySession {
+ private tracker: FlowJourneyTracker | null = null;
+ private stage: FlowStage = 'intro';
+ private updatedAt: number | null = null;
+ constructor(private sink: AnalyticsSink, private storage: Pick,
+ private createId: () => string, private now = Date.now, private metrics: () => Record = () => ({})) {}
+ private activity() {
+ const now = this.now();
+ if (!this.tracker || this.updatedAt === null || now < this.updatedAt || now - this.updatedAt >= JOURNEY_TIMEOUT_MS) {
+ const id = this.tracker ? this.createId() : journeyId(this.storage, this.createId, now);
+ this.tracker = new FlowJourneyTracker(this.sink, id, this.now, this.metrics);
+ this.tracker.view(this.stage);
+ }
+ this.updatedAt = now;
+ try { this.storage.setItem(JOURNEY_STORAGE_KEY, JSON.stringify({ id: this.tracker.id, updatedAt: now })); } catch { /* Optional. */ }
+ return this.tracker;
+ }
+ get id() { return this.activity().id; }
+ view(stage: FlowStage) { this.stage = stage; this.activity().view(stage); }
+ track(event: string, properties: Record = {}) { this.activity().track(event, properties); }
+ markOutcome(outcome: 'cloud_handoff' | 'local_kit_downloaded') { this.activity().markOutcome(outcome); }
+ pause() { this.activity().pause(); }
+ resume() { this.activity().resume(); }
+ leave(reason: 'route_change' | 'pagehide' | 'unmount') {
+ // A second exit signal must not extend inactivity after the tab was hidden.
+ this.tracker?.leave(reason);
+ }
+}
diff --git a/web/lib/flow-local.ts b/web/lib/flow-local.ts
new file mode 100644
index 00000000..ab090105
--- /dev/null
+++ b/web/lib/flow-local.ts
@@ -0,0 +1,71 @@
+import { resolveAgentSettings, rolesForStep } from './flow-agent-settings';
+import { WORKFLOWS } from './flow-workflows';
+import { strToU8, zipSync } from 'fflate';
+import { factorySource, agentLabel, type FactoryDraft } from './flow-onboarding';
+import { workflowAgents } from './flow-workflows';
+
+export const LOCAL_INSTALL = 'npm install --save-dev relayflows@2.0.9 @relayflows/surface@2.0.9';
+export const LOCAL_RUN = 'git switch -c relay/first-flow &&\nnpx flows check software-factory.flow.mts &&\nnpx flows run --local-agent software-factory.flow.mts --input flow-input.json';
+
+export function localInput(draft: FactoryDraft) {
+ if (draft.sources.includes('markdown')) return { approver: 'local' };
+ const source = draft.sources[0];
+ const settings = draft.sourceSettings[source] ?? {};
+ const { labels, contains, ...fields } = settings;
+ return { approver: 'local', issue: {
+ source, title: contains?.trim() || 'Replace with your ticket title',
+ body: 'Replace with the ticket description and acceptance criteria.',
+ labels: labels?.split(',').map(label => label.trim()).filter(Boolean) ?? [],
+ ...fields,
+ } };
+}
+
+export function localKitFiles(draft: FactoryDraft): Record {
+ const agents = workflowAgents(draft.agents);
+ const roles = WORKFLOWS.find(value => value.id === draft.workflow)?.steps.flatMap(rolesForStep) ?? [];
+ const names = [...new Set(roles.length ? roles.map(role => resolveAgentSettings(draft.workflow!, role, draft.agents, draft.agentSettings).agent) : [agents.builder])].map(agentLabel).join(' and ');
+ const markdownPath = draft.sourceSettings.markdown?.path?.trim() || 'tasks.md';
+ const inputStep = draft.sources.includes('markdown')
+ ? `Write the ticket and acceptance criteria in ${markdownPath}, relative to your repository root. The flow reads that file; it is not included in this kit so an existing file cannot be overwritten.`
+ : 'Edit flow-input.json with a real ticket title and description. It is prefilled with your selected source and filters. This starts one ticket manually; it does not subscribe to external issue trackers.';
+ return {
+ 'software-factory.flow.mts': factorySource({ ...draft, step: 3 }, 'local'),
+ 'flow-input.json': JSON.stringify(localInput(draft), null, 2) + '\n',
+ 'START-HERE.txt': `RUN YOUR SOFTWARE FACTORY LOCALLY
+
+Requirements
+- Node.js 22.18+ (for native TypeScript), npm, and Git.
+- macOS on Apple silicon or Linux x64 (bundled runtime platforms).
+- ${names}, installed and signed in.
+- GitHub CLI (gh), signed in, and a repository with push access to origin.
+- Install your repository's dependencies and ensure npm test works. If your project uses another test command, update it in software-factory.flow.mts before running.
+
+1. Extract this kit into your repository root. Keep any existing files before replacing them. Open a terminal in that directory.
+
+2. Install the Flows CLI and authoring dependency:
+${LOCAL_INSTALL}
+
+3. ${inputStep}
+
+4. Start from a clean working tree and create a new branch (choose another name if relay/first-flow already exists), then check and run:
+${LOCAL_RUN}
+
+The flows command starts the local runtime and attaches the local worker. Coding agents use their existing local sign-in; no Agent Relay Cloud account is needed. This flow edits code, runs tests, pushes the branch, and opens a pull request.
+
+Local runtime behavior
+This local version uses a one-hour wall-clock budget instead of the example $8/run budget, which requires declared model pricing. Model usage is billed by your coding-agent provider; this is not a dollar cap.
+Every preset reports needs_human (exit code 3) after its checks and any agent reviews pass. This is the intended manual approval stop, not a failed run. Review and merge the PR in GitHub; the flow never merges automatically and does not resume automatically after approval.
+${draft.workflow === 'prototype' ? 'Prototype worktrees remain available under the generated temporary directory for inspection.' : ''}
+
+GitHub merge protection
+In the repository's branch rules for the target branch, require a pull request, an approving review, and passing CI status checks. Dismiss stale approvals when new commits are pushed. Choose a human reviewer or CODEOWNER. These settings must be configured by a repository administrator; this kit does not change them.
+https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches
+
+Your source selections and filters are included in the flow. Connecting an external event source to trigger future runs is a separate setup step.
+`,
+ };
+}
+
+export function localKitArchive(draft: FactoryDraft): Uint8Array {
+ return zipSync(Object.fromEntries(Object.entries(localKitFiles(draft)).map(([name, text]) => [name, strToU8(text)])));
+}
diff --git a/web/lib/flow-onboarding.ts b/web/lib/flow-onboarding.ts
new file mode 100644
index 00000000..a23043e9
--- /dev/null
+++ b/web/lib/flow-onboarding.ts
@@ -0,0 +1,120 @@
+import { validFlowAgentSettings, type FlowAgentSettings } from './flow-agent-settings';
+import { WORKFLOWS, workflowCode, workflowAgents, type WorkflowId } from './flow-workflows';
+import { ISSUE_SOURCES, issueSourceCode, validSourcePreferences, type IssueSourceId, type SourcePreferences } from './flow-sources';
+
+import { CODING_AGENTS, isCodingAgent, type AgentId, type CodingAgent } from './flow-agents';
+export { CODING_AGENTS, isCodingAgent, type AgentId, type CodingAgent } from './flow-agents';
+
+export type FactoryDraft = {
+ version: 4;
+ sources: IssueSourceId[];
+ sourceSettings: SourcePreferences;
+ agents: AgentId[];
+ otherAgent: string;
+ agentSettings?: FlowAgentSettings;
+ otherAgentSelected?: boolean;
+ task: string;
+ workflow: WorkflowId | null;
+ step: number;
+};
+export const LEGACY_FACTORY_DRAFT_KEY = 'agentrelay:software-factory:v2';
+export const PREVIOUS_FACTORY_DRAFT_KEY = 'agentrelay:software-factory:v3';
+export const FACTORY_DRAFT_KEY = 'agentrelay:software-factory:v4';
+export const DEFAULT_FACTORY: FactoryDraft = { version: 4, sources: [], sourceSettings: {}, agents: [], otherAgent: '', task: '', workflow: null, step: 0 };
+export const agentLabel = (id: AgentId) => CODING_AGENTS.find(agent => agent.id === id)!.label;
+export function primaryAgent(draft: FactoryDraft): CodingAgent {
+ return workflowAgents(draft.agents).builder;
+}
+export function otherAgentIsSelected(draft: FactoryDraft): boolean {
+ return draft.otherAgentSelected ?? Boolean(draft.otherAgent?.trim());
+}
+export function canContinue(draft: FactoryDraft, step = draft.step): boolean {
+ return [draft.sources.length > 0, draft.agents.length > 0 || (otherAgentIsSelected(draft) && Boolean(draft.otherAgent?.trim())), WORKFLOWS.some(workflow => workflow.id === draft.workflow)][step] ?? true;
+}
+
+export const ONBOARDING_STAGES = ['sources', 'agents', 'task', 'connections'] as const;
+export function onboardingPath(step: number): string {
+ return step < 0 ? '/flows/onboarding' : `/flows/onboarding/${ONBOARDING_STAGES[step]}`;
+}
+export function accessibleOnboardingStep(draft: FactoryDraft, requestedStep: number): number {
+ for (let step = 0; step < requestedStep; step++) {
+ if (!canContinue(draft, step)) return step;
+ }
+ return requestedStep;
+}
+export function readFactoryDraft(raw: string | null): FactoryDraft | null {
+ try {
+ const value = JSON.parse(raw ?? 'null');
+ if (!validFlowAgentSettings(value?.agentSettings) || ![2, 3, 4].includes(value?.version) || !Array.isArray(value.agents) ||
+ !value.agents.every((id: unknown) => CODING_AGENTS.some(agent => agent.id === id)) ||
+ (value.otherAgent !== undefined && (typeof value.otherAgent !== 'string' || value.otherAgent.length > 100)) ||
+ (value.otherAgentSelected !== undefined && typeof value.otherAgentSelected !== 'boolean') ||
+ typeof value.task !== 'string' || value.task.length > 600 ||
+ (value.version === 4 && value.workflow !== null && !WORKFLOWS.some(workflow => workflow.id === value.workflow)) || !Number.isInteger(value.step) || value.step < 0 || value.step > (value.version === 2 ? 5 : value.version === 3 ? 6 : 3)) return null;
+ if (value.version >= 3 && (!Array.isArray(value.sources) ||
+ !value.sources.every((id: unknown) => ISSUE_SOURCES.some(source => source.id === id)) ||
+ !validSourcePreferences(value.sourceSettings))) return null;
+ const draft: FactoryDraft = { version: 4,
+ sources: value.version === 2 ? [] : [...new Set(value.sources)],
+ sourceSettings: value.version === 2 ? {} : value.sourceSettings, agents: [...new Set(value.agents)], otherAgent: value.otherAgent ?? '', task: value.task,
+ ...(value.agentSettings !== undefined ? { agentSettings: value.agentSettings } : {}),
+ ...(value.otherAgentSelected !== undefined ? { otherAgentSelected: value.otherAgentSelected } : {}),
+ workflow: value.version === 4 ? value.workflow : null, step: value.version === 2 ? 0 : value.version === 3 ? Math.min(value.step, 2) : value.step };
+ for (let step = 0; step < draft.step; step++) {
+ if (!canContinue(draft, step)) { draft.step = step; break; }
+ }
+ return draft;
+ } catch { return null; }
+}
+
+export function cloudConnectionsHref(draft: FactoryDraft, handoffId: string, journeyId?: string): string {
+ if (!canContinue(draft, 2)) throw new Error('Choose a workflow before continuing to Cloud.');
+ // The fragment is read only by Cloud's browser handoff page. Source code and
+ // ticket filters must not enter OAuth state, cookies, or server access logs.
+ const payload = { version: 1, handoffId, ...(journeyId ? { analytics: { journeyId } } : {}), name: 'Software factory', source: factorySource({ ...draft, step: 3 }),
+ workflow: draft.workflow, sources: draft.sources, sourceSettings: draft.sourceSettings,
+ agents: draft.agents, otherAgent: draft.otherAgent, otherAgentSelected: otherAgentIsSelected(draft), task: draft.task };
+ const base = process.env.NEXT_PUBLIC_CLOUD_URL || 'https://agentrelay.com/cloud';
+ return `${base.replace(/\/$/, '')}/flows/import#${encodeURIComponent(JSON.stringify(payload))}`;
+}
+
+export function factoryCodeSections(draft: FactoryDraft, target: 'cloud' | 'local' = 'cloud') {
+ const budget = target === 'local' ? '{ wallclock: "1h" }' : '"$8/run"';
+ if (!draft.sources.length) return [{ id: 'empty', code: `import { flow } from "@relayflows/surface";
+
+export default flow("software-factory",
+ { budget: ${budget} }, async (f) => {
+
+});` }];
+ const agent = primaryAgent(draft);
+ const hasMarkdown = draft.sources.includes('markdown');
+ const markdownPath = draft.sourceSettings.markdown?.path?.trim() || 'tasks.md';
+ // Quote the configured path as one literal shell argument, including quotes.
+ const readMarkdownCommand = "cat -- '" + markdownPath.replace(/'/g, "'\\''") + "'";
+ const hasBuilder = draft.step >= 1 && canContinue(draft, 1);
+ const sections = [{ id: 'setup', code: 'import { flow } from "@relayflows/surface";' },
+ { id: 'sources', code: issueSourceCode(draft.sources, draft.sourceSettings) }];
+ if (hasBuilder) sections.push({ id: 'builder', code: `${draft.agents.some(isCodingAgent) ? '// Your coding agent, ready to work.' : '// Claude Code example while your selected tools are coming soon.'}
+const builder = "${agent}";` });
+ sections.push({ id: 'input', code: `type Input = { issue${hasMarkdown ? '?' : ''}: Issue; approver: string };
+
+// Run in a connected repository, on a new branch.
+export default flow ("software-factory",
+ { budget: ${budget} }, async (f, input) => {${hasMarkdown ? `
+ // Without an incoming ticket, read your local Markdown task.
+ const issue = input.issue ?? {
+ source: "markdown", title: ${JSON.stringify(markdownPath)},
+ body: await f.run(${JSON.stringify(readMarkdownCommand)}),
+ labels: [], path: ${JSON.stringify(markdownPath)},
+ };` : '\n const issue = input.issue;'}
+ // Ignore items outside the sources and filters you chose.
+ if (!matchesIssue(issue)) return f.done("canceled");` });
+ if (hasBuilder && draft.step >= 2 && draft.workflow) {
+ sections.push(...workflowCode(draft.workflow, workflowAgents(draft.agents), draft.task, target, draft.agentSettings, draft.agents));
+ }
+ sections.push({ id: 'end', code: draft.step < 2 || !draft.workflow ? ' // Your next answer adds the next step.\n});' : '});' });
+ return sections;
+}
+export function factorySource(draft: FactoryDraft, target: 'cloud' | 'local' = 'cloud'): string {
+ return factoryCodeSections(draft, target).map(section => section.code).join('\n\n') + '\n';
+}
diff --git a/web/lib/flow-sources.ts b/web/lib/flow-sources.ts
new file mode 100644
index 00000000..ee091bb5
--- /dev/null
+++ b/web/lib/flow-sources.ts
@@ -0,0 +1,101 @@
+export const ISSUE_SOURCES = [
+ { id: 'github', label: 'GitHub', fields: [
+ { key: 'repository', label: 'Repository', placeholder: 'owner/repository' },
+ { key: 'labels', label: 'Required labels', placeholder: 'ready-for-agent, bug' },
+ ] },
+ { id: 'linear', label: 'Linear', fields: [
+ { key: 'team', label: 'Team', placeholder: 'Engineering' },
+ { key: 'project', label: 'Project', placeholder: 'Website' },
+ { key: 'labels', label: 'Required labels', placeholder: 'ready-for-agent' },
+ ] },
+ { id: 'shortcut', label: 'Shortcut', fields: [
+ { key: 'workspace', label: 'Workspace', placeholder: 'Your workspace' },
+ { key: 'project', label: 'Project', placeholder: 'Web app' },
+ { key: 'labels', label: 'Required labels', placeholder: 'ready-for-agent' },
+ ] },
+ { id: 'jira', label: 'Jira', fields: [
+ { key: 'project', label: 'Project key', placeholder: 'ENG' },
+ { key: 'labels', label: 'Required labels', placeholder: 'ready-for-agent' },
+ ] },
+ { id: 'slack', label: 'Slack', fields: [
+ { key: 'channel', label: 'Channel', placeholder: '#engineering-requests' },
+ { key: 'contains', label: 'Message contains', placeholder: 'Please fix' },
+ ] },
+ { id: 'markdown', label: 'Markdown file', fields: [
+ { key: 'path', label: 'File path in your repository', placeholder: 'tasks.md' },
+ ] },
+] as const;
+
+export type IssueSourceId = typeof ISSUE_SOURCES[number]['id'];
+export type SourceFilterKey = typeof ISSUE_SOURCES[number]['fields'][number]['key'];
+export type SourceSettings = Partial> & { mentioned?: boolean };
+export type SourcePreferences = Partial>;
+export const sourceLabel = (id: IssueSourceId) => ISSUE_SOURCES.find(source => source.id === id)!.label;
+
+export function validSourcePreferences(value: unknown): value is SourcePreferences {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ return Object.entries(value).every(([id, settings]) => {
+ const source = ISSUE_SOURCES.find(source => source.id === id);
+ if (!source || !settings || typeof settings !== 'object' || Array.isArray(settings)) return false;
+ return Object.entries(settings).every(([key, val]) => key === 'mentioned'
+ ? id === 'slack' && typeof val === 'boolean'
+ : source.fields.some(field => field.key === key) && typeof val === 'string' && val.length <= 200);
+ });
+}
+
+export function sourceSummary(id: IssueSourceId, settings: SourceSettings): string {
+ if (id === 'markdown') return `${settings.path?.trim() || 'tasks.md'} · No source integration needed`;
+ const parts = ISSUE_SOURCES.find(source => source.id === id)!.fields.flatMap(field => {
+ const value = settings[field.key]?.trim();
+ return value ? [`${field.label}: ${value}`] : [];
+ });
+ if (id === 'slack' && settings.mentioned) parts.push('Only when the app is mentioned');
+ return parts.join(' · ') || 'All incoming items from this connection';
+}
+
+export function issueSourceCode(sources: IssueSourceId[], preferences: SourcePreferences): string {
+ const rules = Object.fromEntries(sources.map(id => {
+ const settings = preferences[id] ?? {};
+ const entries: [string, string | string[] | boolean][] = [];
+ for (const field of ISSUE_SOURCES.find(source => source.id === id)!.fields) {
+ const value = field.key === 'path' ? settings.path?.trim() || 'tasks.md' : settings[field.key]?.trim();
+ if (value) entries.push([field.key, field.key === 'labels'
+ ? [...new Set(value.split(',').map(label => label.trim()).filter(Boolean))] : value]);
+ }
+ if (id === 'slack' && settings.mentioned) entries.push(['mentioned', true]);
+ return [id, Object.fromEntries(entries)];
+ }));
+ return `// External sources supply Issue input; Markdown is read locally.
+export type Issue = {
+ source: string; title: string; body: string;
+ labels: string[]; mentioned?: boolean;
+ repository?: string; team?: string;
+ workspace?: string; project?: string; channel?: string; path?: string;
+};
+
+// Every configured filter must match. Empty filters allow all items.
+export const sourceFilters: Record> = ${JSON.stringify(rules, null, 2)};
+
+export function matchesIssue(issue: Issue): boolean {
+ if (!issue || typeof issue.source !== "string" ||
+ typeof issue.title !== "string" || typeof issue.body !== "string" ||
+ !Array.isArray(issue.labels) ||
+ !issue.labels.every(label => typeof label === "string")) return false;
+ if (!Object.hasOwn(sourceFilters, issue.source)) return false;
+ const normalize = (value: string) => value.trim().toLowerCase();
+ return Object.entries(sourceFilters[issue.source]).every(([key, value]) => {
+ if (key === "labels") return (value as string[]).every(label =>
+ issue.labels.map(normalize).includes(normalize(label)));
+ if (key === "mentioned") return issue.mentioned === true;
+ if (key === "contains") return normalize(issue.title + " " + issue.body)
+ .includes(normalize(value as string));
+ const actual = issue[key as keyof Issue];
+ if (typeof actual !== "string") return false;
+ if (key === "path") return actual === value;
+ if (key === "channel") return normalize(actual).replace(/^#/, "") ===
+ normalize(value as string).replace(/^#/, "");
+ return normalize(actual) === normalize(value as string);
+ });
+}`;
+}
diff --git a/web/lib/flow-workflows.ts b/web/lib/flow-workflows.ts
new file mode 100644
index 00000000..2917b1e3
--- /dev/null
+++ b/web/lib/flow-workflows.ts
@@ -0,0 +1,106 @@
+import { resolveAgentSettings, type AgentRole, type FlowAgentSettings } from './flow-agent-settings';
+import { isCodingAgent } from './flow-agents';
+
+export const WORKFLOWS = [
+ { id: 'traditional', label: 'Traditional', description: 'Plan first, challenge the code, and keep the final say.', steps: ['Plan', 'Review plan', 'Implement', 'Run checks', 'Open PR', '2× adversarial review', 'Human gate'] },
+ { id: 'prototype', label: 'Prototype and build', description: 'Explore three approaches, then build from the best of each.', steps: ['3 implementations', 'Compare', 'Build', 'Run checks', 'Open PR', 'Review', 'Human gate'] },
+ { id: 'simple', label: 'Simple', description: 'One agent takes the ticket straight to implementation.', steps: ['Implement', 'Run checks', 'Open PR', 'Human gate'] },
+] as const;
+
+export type WorkflowStep = typeof WORKFLOWS[number]['steps'][number];
+export const WORKFLOW_STEP_DETAILS: Record = {
+ 'Plan': 'Read the ticket and repository, then outline the changes to make.',
+ 'Review plan': 'Challenge the assumptions and fill gaps before any code is written.',
+ 'Implement': 'Turn the ticket into a working change and add regression tests.',
+ '3 implementations': 'Build three different solutions in parallel. Each gets its own isolated workspace.',
+ 'Compare': 'Compare the code and test results, then identify the strongest ideas from each solution.',
+ 'Build': 'Use the comparison to combine the strongest ideas into one final implementation.',
+ 'Run checks': 'Run the repository’s tests with a script. Stop if any check fails.',
+ 'Open PR': 'Push the branch and open a pull request with a summary of the changes.',
+ '2× adversarial review': 'Challenge the code in two fresh reviews, fixing and retesting issues between rounds.',
+ 'Review': 'Check the final code against the ticket and comparison. Stop if issues remain.',
+ 'Human gate': 'Stop for your review. You decide when to merge the pull request in GitHub.',
+};
+
+export type WorkflowId = (typeof WORKFLOWS)[number]['id'];
+
+export function workflowAgents(selected: readonly string[]) {
+ const builder = selected.filter(isCodingAgent)[0] ?? 'claude';
+ const reviewer = selected.filter(isCodingAgent).find(id => id !== builder) ?? builder;
+ return { builder, reviewer, prototypes: [builder, reviewer, builder] };
+}
+
+export function workflowCode(workflow: WorkflowId, agents: ReturnType, instructions: string, _target: 'cloud' | 'local' = 'cloud', settings: FlowAgentSettings = {}, selected: readonly string[] = [agents.builder, agents.reviewer]) {
+ const config = (role: AgentRole) => resolveAgentSettings(workflow, role, selected, settings);
+ const options = (role: AgentRole, fallback: string, context = '') => {
+ const value = config(role);
+ const cli = value.agent === agents.builder && fallback === 'builder' ? 'builder' : JSON.stringify(value.agent);
+ return `cli: ${cli},${value.model ? `\n model: ${JSON.stringify(value.model)},` : ''}\n task: task + "\\n" + ${JSON.stringify(value.prompt)}${context},`;
+ };
+ const prototypeConfigs = (['prototype-1', 'prototype-2', 'prototype-3'] as const).map(config);
+ const sections = [{ id: 'task', code: ` const task = issue.title + "\\n" + issue.body + "\\n" +
+ ${JSON.stringify(instructions.trim() || 'Follow existing patterns. Keep changes focused and add regression tests.')};` }];
+ if (workflow === 'traditional') sections.push({ id: 'plan', code: ` // Read the ticket and agree on a plan before changing code.
+ await f.agent("planner", {
+ ${options('planner', 'builder')}
+ });
+ await f.agent("plan-reviewer", {
+ ${options('plan-reviewer', 'reviewer')}
+ });
+ await f.run("test -s reviewed-plan.md");` });
+ if (workflow === 'prototype') sections.push({ id: 'prototypes', code: ` // Each prototype starts from the same commit in its own worktree.
+ const prototypeRoot = (await f.run("mktemp -d /tmp/relay-prototypes.XXXXXX")).trim();
+ const quote = (value: string) => "'" + value.replace(/'/g, "'\\\\''") + "'";
+ const prototypeAgents = ${JSON.stringify(prototypeConfigs.map(value => value.agent))};
+ const prototypeSettings: { model?: string; prompt: string }[] = ${JSON.stringify(prototypeConfigs.map(({ model, prompt }) => ({ ...(model ? { model } : {}), prompt })))};
+ const approaches = ["the smallest change", "a maintainable design", "a different approach"];
+ const paths = approaches.map((_, index) => prototypeRoot + "/" + (index + 1));
+ const base = (await f.run("git rev-parse HEAD")).trim();
+ for (const path of paths) {
+ await f.run("git worktree add --detach " + quote(path) + " " + quote(base));
+ }
+ await Promise.all(paths.map((cwd, index) => f.agent("prototype-" + (index + 1), {
+ cli: prototypeAgents[index],
+ cwd,
+ ...(prototypeSettings[index].model ? { model: prototypeSettings[index].model } : {}),
+ task: task + "\\n" + prototypeSettings[index].prompt + " Assigned approach: " + approaches[index],
+ })));
+ // All three implementations are finished before comparison begins.
+ await f.agent("comparator", {
+ ${options('comparator', 'reviewer', ' + " Prototype worktrees: " + paths.join(", ")')}
+ });
+ await f.run("test -s comparison.md");
+ // Keep the prototype worktrees available for inspection.` });
+ sections.push({ id: 'implement', code: ` // Build and test the change, then open a pull request.
+ await f.agent("implementer", {
+ ${options('implementer', 'builder')}
+ });` });
+ sections.push({ id: 'checks', code: ` // Scripted checks must pass before publishing the change.
+ await f.run("npm test", { timeout: "15m" });` });
+ sections.push({ id: 'pull-request', code: ` // Publish the branch and open the pull request without an agent.
+ await f.run("git push --set-upstream origin HEAD");
+ await f.run('gh pr create --title "Software factory change" --body-file summary.md');` });
+ if (workflow !== 'simple') sections.push({ id: 'review', code: ` // ${workflow === 'traditional' ? 'Always run two independent adversarial reviews, even if the first passes.' : 'Review the final implementation against the ticket and comparison findings.'}
+ let clean = false;
+ for (let round = 0; round < ${workflow === 'traditional' ? 2 : 1}; round++) {
+ await f.run("rm -f review.clean");
+ await f.agent("adversary-" + (round + 1), {
+ ${options('adversary', 'reviewer')}
+ });
+ clean = (await f.run("test -f review.clean && echo yes || echo no")).trim() === "yes";
+ ${workflow === 'traditional' ? `if (!clean && round === 0) {
+ await f.agent("fixer", {
+ ${options('fixer', 'builder')}
+ });
+ await f.run("npm test", { timeout: "15m" });
+ await f.run("git push");
+ }` : ''}
+ }
+ // Unresolved feedback stops the flow.
+ if (!clean) return f.done("step_failed");` });
+ sections.push({ id: 'gate', code: ` // Require approving reviews and passing CI checks in GitHub branch rules.
+ // Stop for human review. This flow never merges the pull request.
+ // Review and merge in GitHub; approval happens outside the runner.
+ return f.done("needs_human");` });
+ return sections;
+}
diff --git a/web/lib/site-analytics.ts b/web/lib/site-analytics.ts
index be4156ef..cecdd603 100644
--- a/web/lib/site-analytics.ts
+++ b/web/lib/site-analytics.ts
@@ -1,4 +1,5 @@
const TRACKED_ROUTE_PREFIXES = [
+ { prefix: '/flows', pageGroup: 'flows' },
{ prefix: '/docs', pageGroup: 'docs' },
{ prefix: '/blog', pageGroup: 'blog' },
{ prefix: '/primitives', pageGroup: 'primitives' },
diff --git a/web/lib/test/feature-request-demo.test.ts b/web/lib/test/feature-request-demo.test.ts
new file mode 100644
index 00000000..3d9030e5
--- /dev/null
+++ b/web/lib/test/feature-request-demo.test.ts
@@ -0,0 +1,170 @@
+import { describe, expect, it } from 'vitest';
+import {
+ CANVAS_WIDTH, NODE_WIDTH, workflowCameraTarget, workflowChapters,
+ workflowEdges, workflowNodes, workflowPhases,
+} from '../feature-request-demo';
+
+const byPhase = (name: string) => workflowPhases.find((phase) => phase.name === name)!;
+
+describe('feature request animation', () => {
+ it('connects every animated route to existing nodes and coherent states', () => {
+ const ids = workflowNodes.map((node) => node.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ expect(new Set(workflowEdges.map((edge) => edge.id)).size).toBe(workflowEdges.length);
+ for (const edge of workflowEdges) {
+ expect(ids).toContain(edge.from);
+ expect(ids).toContain(edge.to);
+ }
+ for (const phase of workflowPhases) {
+ expect(phase.duration).toBeGreaterThan(0);
+ for (const id of [...phase.active, ...phase.done, ...(phase.failed ?? [])]) expect(ids).toContain(id);
+ expect(phase.active.filter((id) => phase.done.includes(id))).toEqual([]);
+ for (const id of phase.routes) expect(workflowEdges.some((edge) => edge.id === id)).toBe(true);
+ }
+ expect(workflowChapters.every((chapter) => chapter.index >= 0)).toBe(true);
+ });
+
+ it('keeps implementation locked until both research branches satisfy the priority gate', () => {
+ expect(byPhase('research').active).toEqual(['competitors', 'retention']);
+ expect(byPhase('more-evidence').routes).toEqual(['deepen-research', 'deepen-risk']);
+ expect(byPhase('more-evidence').priorityPassed).toBe(false);
+ expect(byPhase('priority-recheck').done).toEqual(expect.arrayContaining(['competitors', 'retention']));
+ expect(byPhase('priority-recheck').priorityPassed).toBe(false);
+ expect(byPhase('priority-passed').priorityPassed).toBe(true);
+ for (const phase of workflowPhases.filter((item) => !item.priorityPassed)) {
+ expect(phase.active).not.toContain('frontend');
+ expect(phase.active).not.toContain('backend');
+ expect(phase.routes).not.toContain('build-ui');
+ expect(phase.routes).not.toContain('build-auth');
+ }
+ });
+
+ it('actually traverses backwards for revisions and repeats review after failed tests', () => {
+ for (const name of ['revise', 'revise-again', 'fix-test']) {
+ const phase = byPhase(name);
+ expect(phase.routes.length).toBeGreaterThan(0);
+ for (const id of phase.routes) {
+ const edge = workflowEdges.find((item) => item.id === id)!;
+ expect(edge.loop).toBe(true);
+ expect(workflowNodes.find((node) => node.id === edge.to)!.x)
+ .toBeLessThan(workflowNodes.find((node) => node.id === edge.from)!.x);
+ }
+ expect(phase.testsPassed).toBe(false);
+ }
+ expect(byPhase('revise').revision).toBe(1);
+ expect(byPhase('revise-again').revision).toBe(2);
+ expect(byPhase('test-failed').failed).toEqual(['tests']);
+ expect(byPhase('review-fix').active).toEqual(['backend-review']);
+ expect(byPhase('retest').done).toEqual(expect.arrayContaining(['frontend-review', 'backend-review']));
+ for (const phase of workflowPhases.filter((item) => !item.testsPassed)) {
+ expect(phase.routes).not.toContain('feature-ready');
+ expect(phase.active).not.toContain('email-agent');
+ expect(phase.active).not.toContain('crm-agent');
+ }
+ });
+
+ it('hands off each coding branch independently while its peer keeps working', () => {
+ expect(byPhase('ui-ready').done).toContain('frontend');
+ expect(byPhase('ui-ready').active).toEqual(['backend', 'frontend-review']);
+ expect(byPhase('ui-ready').routes).toEqual(['review-ui']);
+ expect(byPhase('auth-revision-ready').done).toContain('backend');
+ expect(byPhase('auth-revision-ready').active).toEqual(['frontend', 'backend-review']);
+ expect(byPhase('auth-revision-ready').routes).toEqual(['review-auth']);
+ expect(byPhase('auth-review-ready').done).toContain('backend-review');
+ expect(byPhase('auth-review-ready').active).toEqual(['frontend-review']);
+ expect(byPhase('ui-review-ready').done).toContain('frontend-review');
+ expect(byPhase('ui-review-ready').active).toEqual(['backend-review']);
+ for (const phase of workflowPhases) {
+ if (phase.active.includes('frontend-review')) expect(phase.done).toContain('frontend');
+ if (phase.active.includes('backend-review')) expect(phase.done).toContain('backend');
+ if (phase.active.includes('tests')) expect(phase.done).toEqual(expect.arrayContaining(['frontend-review', 'backend-review']));
+ }
+ });
+
+ it('joins both Claude updates directly into Slack after both finish', () => {
+ expect(byPhase('notify').active).toEqual(['crm-agent', 'email-agent']);
+ for (const id of byPhase('notify').active) expect(workflowNodes.find((node) => node.id === id)!.icon).toBe('claude');
+ expect(byPhase('notify').khaliqApproved).toBe(true);
+ expect(byPhase('waiting-for-email').done).toContain('crm-updated');
+ expect(byPhase('waiting-for-email').done).not.toContain('email-sent');
+ expect(byPhase('waiting-for-email').updatesPassed).toBe(false);
+ expect(byPhase('send-slack').routes).toEqual(['crm-ready', 'email-ready']);
+ expect(byPhase('send-slack').updatesPassed).toBe(true);
+ expect(workflowEdges.filter((edge) => edge.to === 'slack-sent').map((edge) => edge.from)).toEqual(['crm-updated', 'email-sent']);
+ for (const phase of workflowPhases) {
+ if (phase.active.includes('slack-sent')) {
+ expect(phase.done).toEqual(expect.arrayContaining(['crm-updated', 'email-sent']));
+ expect(phase.updatesPassed).toBe(true);
+ }
+ }
+ expect(byPhase('complete').done).toHaveLength(workflowNodes.length);
+ });
+
+ it('routes Will’s question back to Codex and requests approval again after revision', () => {
+ const question = byPhase('will-question');
+ const revision = byPhase('revise-priority');
+ const reapproval = byPhase('will-reapproval');
+ expect(question.feedback?.question).toMatch(/renewal\?/);
+ expect(revision.feedback?.question).toBe(question.feedback?.question);
+ expect(revision.active).toEqual(['priority']);
+ expect(revision.priorityPassed).toBe(false);
+ expect(revision.routes).toEqual(['will-revision']);
+ expect(workflowEdges.find((edge) => edge.id === 'will-revision')).toMatchObject({ from: 'will-approval', to: 'priority', loop: true });
+ expect(reapproval.feedback?.answer).toBe(revision.feedback?.answer);
+ expect(reapproval.routes).toEqual(['request-will']);
+ expect(reapproval.priorityPassed).toBe(true);
+ expect(reapproval.willApproved).toBe(false);
+ expect(workflowPhases.indexOf(reapproval)).toBeLessThan(workflowPhases.indexOf(byPhase('will-approved')));
+ });
+
+ it('requires Will before implementation and Khaliq after tests before continuing', () => {
+ expect(byPhase('will-approval').active).toEqual(['will-approval']);
+ expect(byPhase('will-approval').done).not.toContain('will-approval');
+ expect(byPhase('will-approval').priorityPassed).toBe(true);
+ expect(byPhase('will-approval').willApproved).toBe(false);
+ expect(byPhase('will-approved').willApproved).toBe(true);
+ expect(byPhase('khaliq-approval').active).toEqual(['khaliq-approval']);
+ expect(byPhase('khaliq-approval').done).not.toContain('khaliq-approval');
+ expect(byPhase('khaliq-approval').testsPassed).toBe(true);
+ expect(byPhase('khaliq-approval').khaliqApproved).toBe(false);
+ expect(byPhase('khaliq-approved').khaliqApproved).toBe(true);
+ for (const phase of workflowPhases) {
+ if (!phase.willApproved) {
+ expect(phase.active).not.toContain('frontend');
+ expect(phase.active).not.toContain('backend');
+ expect(phase.routes).not.toContain('build-ui');
+ expect(phase.routes).not.toContain('build-auth');
+ }
+ if (!phase.khaliqApproved) {
+ expect(phase.routes).not.toContain('draft-crm');
+ expect(phase.routes).not.toContain('draft-email');
+ expect(phase.active).not.toContain('email-agent');
+ expect(phase.active).not.toContain('crm-agent');
+ }
+ }
+ expect(workflowEdges.find((edge) => edge.id === 'build-ui')!.from).toBe('will-approval');
+ expect(workflowEdges.find((edge) => edge.id === 'build-auth')!.from).toBe('will-approval');
+ expect(workflowEdges.find((edge) => edge.id === 'request-khaliq')!.from).toBe('feature');
+ expect(byPhase('khaliq-approval').done).toEqual(expect.arrayContaining(['feature', 'frontend-review', 'backend-review', 'tests']));
+ expect(workflowPhases.indexOf(byPhase('feature-ready'))).toBeLessThan(workflowPhases.indexOf(byPhase('khaliq-approval')));
+ expect(workflowNodes.find((node) => node.id === 'will-approval')).toMatchObject({
+ action: 'Get approval from Will', photo: '/authors/will.png', gate: true,
+ });
+ expect(workflowNodes.find((node) => node.id === 'khaliq-approval')).toMatchObject({
+ action: 'Get approval from Khaliq', photo: '/authors/khaliq.jpeg', gate: true,
+ });
+ });
+
+ it('keeps the focused node inside the frame at mobile and desktop sizes', () => {
+ for (const width of [266, 320, 545, 800]) {
+ for (const [index, phase] of workflowPhases.entries()) {
+ const offset = workflowCameraTarget(index, width);
+ const node = workflowNodes.find((item) => item.id === phase.focus)!;
+ expect(offset).toBeGreaterThanOrEqual(0);
+ expect(offset).toBeLessThanOrEqual(CANVAS_WIDTH - width);
+ expect(node.x - offset).toBeGreaterThanOrEqual(0);
+ expect(node.x + NODE_WIDTH - offset).toBeLessThanOrEqual(width);
+ }
+ }
+ });
+});
diff --git a/web/lib/test/flow-agent-settings.test.ts b/web/lib/test/flow-agent-settings.test.ts
new file mode 100644
index 00000000..c9781b80
--- /dev/null
+++ b/web/lib/test/flow-agent-settings.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest';
+import ts from 'typescript';
+import { DEFAULT_FACTORY, factorySource, readFactoryDraft, cloudConnectionsHref, type FactoryDraft } from '../flow-onboarding';
+import { resolveAgentSettings } from '../flow-agent-settings';
+import { localKitFiles } from '../flow-local';
+
+const draft: FactoryDraft = { ...DEFAULT_FACTORY, sources: ['github'], agents: ['claude', 'codex', 'cursor', 'opencode'], workflow: 'prototype', step: 3 };
+async function execute(value: FactoryDraft) {
+ const source = factorySource(value).replace('import { flow } from "@relayflows/surface";', '');
+ const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } });
+ const exports: { default?: (ctx: unknown, input: unknown) => Promise } = {};
+ new Function('exports', 'flow', compiled.outputText)(exports, (_name: string, _options: unknown, body: unknown) => body);
+ const calls: Record = {};
+ await exports.default!({ agent: async (name: string, options: typeof calls[string]) => { calls[name] = options; },
+ run: async (command: string) => command.startsWith('mktemp') ? '/tmp/prototypes' : command.includes('review.clean &&') ? 'yes' : 'base', done: () => {} },
+ { issue: { source: 'github', title: 'Ticket title', body: 'Ticket body', labels: [] } });
+ return calls;
+}
+
+describe('per-step agent settings', () => {
+ it('inherits CLI models and adapts assignments to the selected agents', () => {
+ expect(resolveAgentSettings('traditional', 'planner', ['cursor', 'opencode'])).toMatchObject({ agent: 'cursor', model: '' });
+ expect(resolveAgentSettings('traditional', 'adversary', ['cursor', 'opencode'])).toMatchObject({ agent: 'opencode', model: '' });
+ expect(resolveAgentSettings('prototype', 'prototype-2', ['codex']).agent).toBe('codex');
+ expect(resolveAgentSettings('simple', 'implementer', ['claude'], { 'simple:implementer': { agent: 'cursor', model: 'cursor-model', prompt: 'Custom work' } })).toMatchObject({ agent: 'claude', model: '', prompt: 'Custom work' });
+ });
+
+ it('runs distinct prototype overrides while preserving ticket and worktree context', async () => {
+ const prompt = 'Use "quotes", `ticks`, ${literal}, and a newline.\nWrite prototype-notes.md.';
+ const calls = await execute({ ...draft, task: 'Keep changes focused.', agentSettings: {
+ 'prototype:prototype-1': { agent: 'cursor', model: 'model-one', prompt },
+ 'prototype:prototype-2': { agent: 'opencode', model: 'model-two' },
+ 'prototype:comparator': { agent: 'codex', prompt: 'Write comparison.md.' },
+ 'prototype:implementer': { agent: 'opencode', model: 'build-model' },
+ } });
+ expect(calls['prototype-1']).toMatchObject({ cli: 'cursor', model: 'model-one', cwd: '/tmp/prototypes/1' });
+ expect(calls['prototype-1'].task).toContain(prompt);
+ expect(calls['prototype-1'].task).toContain('Ticket title\nTicket body\nKeep changes focused.');
+ expect(calls['prototype-1'].task).toContain('Assigned approach: the smallest change');
+ expect(calls['prototype-2'].model).toBe('model-two');
+ expect(calls['prototype-3'].model).toBeUndefined();
+ expect(calls.comparator.task).toContain('/tmp/prototypes/1, /tmp/prototypes/2, /tmp/prototypes/3');
+ expect(calls.implementer).toMatchObject({ cli: 'opencode', model: 'build-model' });
+ });
+
+ it('applies the shared reviewer settings to both traditional rounds', async () => {
+ const calls = await execute({ ...draft, workflow: 'traditional', agentSettings: { 'traditional:adversary': { agent: 'cursor', model: 'review-model', prompt: 'Check the diff. Write review.clean only if clean.' } } });
+ for (const role of ['adversary-1', 'adversary-2']) expect(calls[role]).toMatchObject({ cli: 'cursor', model: 'review-model' });
+ expect(calls.planner.model).toBeUndefined();
+ });
+
+ it('persists valid overrides and includes them in both handoff sources', () => {
+ const value: FactoryDraft = { ...draft, agentSettings: { 'prototype:implementer': { agent: 'cursor', model: 'custom-model', prompt: 'Write summary.md.' } } };
+ expect(readFactoryDraft(JSON.stringify(value))?.agentSettings).toEqual(value.agentSettings);
+ expect(localKitFiles(value)['software-factory.flow.mts']).toContain('model: "custom-model"');
+ expect(localKitFiles(value)['START-HERE.txt']).toContain('Cursor');
+ const cloud = JSON.parse(decodeURIComponent(new URL(cloudConnectionsHref(value, 'test')).hash.slice(1)));
+ expect(cloud.source).toContain('model: "custom-model"');
+ expect(cloud.source).toContain('Write summary.md.');
+ });
+
+ it('rejects invalid persisted settings without inventing reasoning options', () => {
+ for (const agentSettings of [[], { 'simple:unknown': {} }, { 'simple:implementer': { agent: 'shell' } }, { 'simple:implementer': { reasoning: 'high' } }, { 'simple:implementer': { prompt: ' ' } }, { 'simple:implementer': { model: 'bad\nmodel' } }]) {
+ expect(readFactoryDraft(JSON.stringify({ ...draft, agentSettings }))).toBeNull();
+ }
+ expect(factorySource(draft)).not.toContain('reasoning:');
+ });
+});
diff --git a/web/lib/test/flow-analytics.test.ts b/web/lib/test/flow-analytics.test.ts
new file mode 100644
index 00000000..e2b997b7
--- /dev/null
+++ b/web/lib/test/flow-analytics.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it, vi } from 'vitest';
+import { FlowJourneySession, JOURNEY_TIMEOUT_MS, FlowJourneyTracker, flowMetrics, journeyId, JOURNEY_STORAGE_KEY, lengthBucket } from '../flow-analytics';
+import { cloudConnectionsHref, DEFAULT_FACTORY } from '../flow-onboarding';
+
+const id = '00000000-0000-4000-8000-000000000001';
+const freshId = '00000000-0000-4000-8000-000000000002';
+function fixture() {
+ let now = 0;
+ const sink = vi.fn();
+ const tracker = new FlowJourneyTracker(sink, id, () => now);
+ return { tracker, sink, advance: (ms: number) => { now += ms; } };
+}
+
+describe('onboarding funnel accounting', () => {
+ it('counts Cursor and OpenCode as supported agents', () => {
+ expect(flowMetrics({ ...DEFAULT_FACTORY, agents: ['cursor', 'opencode', 'pi'] })).toMatchObject({
+ supported_agent_count: 2, requested_agent_count: 1,
+ });
+ });
+ it('deduplicates rerenders and counts real backwards navigation as a revisit', () => {
+ const { tracker, sink, advance } = fixture();
+ tracker.view('sources'); tracker.view('sources');
+ advance(1000); tracker.view('agents'); tracker.view('sources');
+ const views = sink.mock.calls.filter(([event]) => event.endsWith('step_viewed'));
+ expect(views).toHaveLength(3);
+ expect(views[2][1]).toMatchObject({ stage: 'sources', is_revisit: true, visit_number: 2, journey_id: id });
+ expect(sink.mock.calls[1][1]).toMatchObject({ reason: 'route_change', exit_signal: false, active_ms: 1000 });
+ });
+
+ it('excludes hidden-tab time and deduplicates exit signals', () => {
+ const { tracker, sink, advance } = fixture();
+ tracker.view('task'); advance(1000); tracker.pause(); tracker.pause();
+ advance(10_000); tracker.resume(); advance(2000);
+ tracker.track('choice_changed', { field: 'workflow', to: 'simple' });
+ tracker.leave('pagehide'); tracker.leave('unmount');
+ const exits = sink.mock.calls.filter(([event]) => event.endsWith('step_left'));
+ expect(exits).toHaveLength(1);
+ expect(exits[0]).toEqual(['flows_onboarding_step_left', expect.objectContaining({ active_ms: 3000, elapsed_ms: 13_000, choice_changes: 1, exit_signal: true }), true]);
+ tracker.resume();
+ expect(sink.mock.lastCall?.[1]).toMatchObject({ visit_number: 2, active_ms: 0 });
+ });
+
+ it('distinguishes intended handoffs from unexplained exits and sends them immediately', () => {
+ const { tracker, sink } = fixture();
+ tracker.view('connections'); tracker.track('cloud_handoff_started');
+ expect(sink.mock.lastCall?.[2]).toBe(true);
+ tracker.markOutcome('cloud_handoff'); tracker.leave('pagehide');
+ expect(sink.mock.lastCall?.[1]).toMatchObject({ last_outcome: 'cloud_handoff' });
+ });
+
+ it('does not break the product if analytics throws', () => {
+ const tracker = new FlowJourneyTracker(() => { throw Error('blocked'); }, id);
+ expect(() => { tracker.view('intro'); tracker.track('intro_completed'); tracker.leave('pagehide'); }).not.toThrow();
+ });
+});
+
+describe('safe analytics payloads and correlation', () => {
+ it('reports configuration shape without sending user-entered values', () => {
+ const metrics = flowMetrics({ ...DEFAULT_FACTORY, sources: ['github', 'slack'], agents: ['claude', 'pi'],
+ sourceSettings: { github: { repository: 'private/repo', labels: 'secret-label' }, slack: { channel: '#secret', mentioned: true } },
+ otherAgent: 'Private Agent Name', otherAgentSelected: true, task: 'Confidential instructions' });
+ expect(metrics).toMatchObject({ sources: ['github', 'slack'], supported_agent_count: 1, requested_agent_count: 1, other_agent_selected: true,
+ configured_filters: ['github.repository', 'github.labels', 'slack.channel', 'slack.mentioned'] });
+ for (const secret of ['private/repo', 'secret-label', '#secret', 'Private Agent Name', 'Confidential instructions']) expect(JSON.stringify(metrics)).not.toContain(secret);
+ expect([0, 1, 20, 21, 100, 101].map(lengthBucket)).toEqual(['empty', '1-20', '1-20', '21-100', '21-100', '101+']);
+ });
+
+ it('reuses a recent journey, expires stale or invalid timestamps, and tolerates blocked storage', () => {
+ const storage = { getItem: vi.fn(), setItem: vi.fn() };
+ storage.getItem.mockReturnValue(JSON.stringify({ id, updatedAt: 1000 }));
+ expect(journeyId(storage, () => freshId, 2000)).toBe(id);
+ expect(storage.setItem).toHaveBeenCalledWith(JOURNEY_STORAGE_KEY, JSON.stringify({ id, updatedAt: 2000 }));
+ for (const updatedAt of [0, 4_000_000, 'invalid']) {
+ storage.getItem.mockReturnValue(JSON.stringify({ id, updatedAt }));
+ expect(journeyId(storage, () => freshId, 3_000_000)).toBe(freshId);
+ }
+ storage.getItem.mockImplementation(() => { throw Error('blocked'); });
+ storage.setItem.mockImplementation(() => { throw Error('blocked'); });
+ expect(journeyId(storage, () => freshId)).toBe(freshId);
+ });
+
+ it('carries an optional journey across Cloud auth without adding it to the URL query', () => {
+ const draft = { ...DEFAULT_FACTORY, workflow: 'simple' as const, sources: ['markdown' as const], agents: ['claude' as const], step: 3 };
+ const url = new URL(cloudConnectionsHref(draft, freshId, id));
+ expect(url.search).toBe('');
+ expect(JSON.parse(decodeURIComponent(url.hash.slice(1))).analytics).toEqual({ journeyId: id });
+ expect(JSON.parse(decodeURIComponent(new URL(cloudConnectionsHref(draft, freshId)).hash.slice(1))).analytics).toBeUndefined();
+ });
+});
+
+describe('mounted journey expiration', () => {
+ it('resumes short absences, refreshes activity, and replaces expired trackers', () => {
+ let now = 0;
+ let saved: string | null = null;
+ const sink = vi.fn();
+ const createId = vi.fn().mockReturnValueOnce(id).mockReturnValue(freshId);
+ const storage = { getItem: () => saved, setItem: (_key: string, value: string) => { saved = value; } };
+ const session = new FlowJourneySession(sink, storage, createId, () => now);
+ session.view('task');
+ now = 1000; session.pause();
+ now += JOURNEY_TIMEOUT_MS - 1; session.resume();
+ expect(session.id).toBe(id);
+ expect(JSON.parse(saved!)).toEqual({ id, updatedAt: now });
+ expect(sink.mock.lastCall?.[1]).toMatchObject({ stage: 'task', journey_id: id, active_ms: 1000 });
+ now += 1000; session.track('choice_changed');
+ expect(JSON.parse(saved!).updatedAt).toBe(now);
+ session.pause();
+ now += JOURNEY_TIMEOUT_MS; session.resume();
+ expect(session.id).toBe(freshId);
+ expect(sink.mock.lastCall?.[1]).toMatchObject({ stage: 'task', journey_id: freshId, visit_number: 1, active_ms: 0, choice_changes: 0 });
+ session.resume();
+ expect(createId).toHaveBeenCalledTimes(2);
+ });
+ it('expires even when browser storage is unavailable', () => {
+ let now = 0;
+ const createId = vi.fn().mockReturnValueOnce(id).mockReturnValue(freshId);
+ const blocked = () => { throw Error('blocked'); };
+ const session = new FlowJourneySession(vi.fn(), { getItem: blocked, setItem: blocked }, createId, () => now);
+ session.view('agents'); session.pause();
+ now += JOURNEY_TIMEOUT_MS;
+ session.resume();
+ expect(session.id).toBe(freshId);
+ });
+});
diff --git a/web/lib/test/flow-local.test.ts b/web/lib/test/flow-local.test.ts
new file mode 100644
index 00000000..ec5ca3a7
--- /dev/null
+++ b/web/lib/test/flow-local.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from 'vitest';
+import { strFromU8, unzipSync } from 'fflate';
+import ts from 'typescript';
+import { DEFAULT_FACTORY, factorySource, type FactoryDraft } from '../flow-onboarding';
+import { LOCAL_INSTALL, LOCAL_RUN, localInput, localKitArchive, localKitFiles } from '../flow-local';
+
+const draft: FactoryDraft = { ...DEFAULT_FACTORY, sources: ['github'], sourceSettings: { github: { repository: 'acme/app', labels: 'bug, ready' } }, agents: ['claude', 'codex'], workflow: 'traditional', step: 3 };
+
+function compile(source: string) {
+ const exports: { default?: (f: unknown, input: unknown) => Promise } = {};
+ const compiled = ts.transpileModule(source.replace('import { flow } from "@relayflows/surface";', ''), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } });
+ new Function('exports', 'flow', compiled.outputText)(exports, (_name: string, _header: unknown, fn: unknown) => fn);
+ return exports.default!;
+}
+
+describe('local flow starter kit', () => {
+ it('creates a readable zip with a flow, input, and instructions', () => {
+ const files = localKitFiles(draft);
+ const unpacked = unzipSync(localKitArchive(draft));
+ expect(Object.keys(unpacked).sort()).toEqual(['START-HERE.txt', 'flow-input.json', 'software-factory.flow.mts']);
+ for (const [name, content] of Object.entries(files)) expect(strFromU8(unpacked[name])).toBe(content);
+ expect(files['START-HERE.txt']).toContain(LOCAL_INSTALL);
+ expect(files['START-HERE.txt']).toContain(LOCAL_RUN);
+ expect(LOCAL_RUN).toContain('--local-agent');
+ expect(LOCAL_RUN).toContain('--input flow-input.json');
+ });
+
+ it('preserves issue filters and provides an editable one-ticket input', () => {
+ expect(localInput(draft).issue).toMatchObject({ source: 'github', repository: 'acme/app', labels: ['bug', 'ready'] });
+ const slack = { ...draft, sources: ['slack'] as const, sourceSettings: { slack: { channel: '#build', contains: 'Please fix', mentioned: true } } };
+ expect(localInput({ ...slack, sources: [...slack.sources] }).issue).toMatchObject({ source: 'slack', title: 'Please fix', channel: '#build', mentioned: true });
+ });
+
+ it('does not overwrite the user’s Markdown task file', () => {
+ const markdown: FactoryDraft = { ...draft, sources: ['markdown'], sourceSettings: { markdown: { path: 'docs/ticket.md' } } };
+ expect(localInput(markdown)).toEqual({ approver: 'local' });
+ expect(localKitFiles(markdown)).not.toHaveProperty('docs/ticket.md');
+ expect(localKitFiles(markdown)['START-HERE.txt']).toContain('docs/ticket.md');
+ });
+
+ it('runs the traditional sequence and hands off for manual approval locally', async () => {
+ const calls: string[] = [];
+ let finish = '';
+ await compile(localKitFiles(draft)['software-factory.flow.mts'])({
+ agent: async (name: string) => { calls.push(name); },
+ run: async (command: string) => command.startsWith('test -f') ? 'yes' : '',
+ done: (reason: string) => { finish = reason; },
+ }, localInput(draft));
+ expect(calls).toEqual(['planner', 'plan-reviewer', 'implementer', 'adversary-1', 'adversary-2']);
+ expect(finish).toBe('needs_human');
+ expect(factorySource(draft)).toContain('return f.done("needs_human")');
+ expect(factorySource(draft, 'local')).not.toContain('f.human(');
+ });
+
+
+ it('stops every preset for human review after tests and PR creation', async () => {
+ for (const workflow of ['traditional', 'prototype', 'simple'] as const) {
+ const commands: string[] = [];
+ let finish = '';
+ const selected = { ...draft, workflow };
+ await compile(factorySource(selected, 'local'))({
+ agent: async () => {},
+ run: async (command: string) => { commands.push(command); return command.startsWith('test -f') ? 'yes' : ''; },
+ done: (reason: string) => { finish = reason; },
+ }, localInput(selected));
+ expect(finish).toBe('needs_human');
+ const testIndex = commands.indexOf('npm test');
+ const createIndex = commands.findIndex(command => command.startsWith('gh pr create'));
+ expect(testIndex).toBeGreaterThanOrEqual(0);
+ expect(createIndex).toBeGreaterThan(testIndex);
+ expect(localKitFiles(selected)['START-HERE.txt']).toContain('require a pull request, an approving review, and passing CI status checks');
+ }
+ });
+
+ it('does not publish a PR or signal readiness if scripted tests fail', async () => {
+ for (const workflow of ['traditional', 'prototype', 'simple'] as const) {
+ const commands: string[] = [];
+ let finish = '';
+ const selected = { ...draft, workflow };
+ await expect(compile(factorySource(selected, 'local'))({
+ agent: async () => {},
+ run: async (command: string) => { commands.push(command); if (command === 'npm test') throw Error('tests failed'); return ''; },
+ done: (reason: string) => { finish = reason; },
+ }, localInput(selected))).rejects.toThrow('tests failed');
+ expect(commands.some(command => command.startsWith('git push') || command.startsWith('gh pr create'))).toBe(false);
+ expect(finish).toBe('');
+ }
+ });
+
+ it.each([false, true])('pushes fixer revisions only after passing tests (failure: %s)', async (fail) => {
+ const calls: string[] = [];
+ let checks = 0;
+ const run = compile(factorySource(draft, 'local'))({
+ agent: async (name: string, options: { task: string }) => {
+ calls.push(name);
+ if (name === 'fixer') expect(options.task).toContain('Commit fixes without pushing');
+ },
+ run: async (command: string) => {
+ calls.push(command);
+ if (command === 'npm test' && ++checks === 2 && fail) throw Error('revision failed');
+ return command.startsWith('test -f') ? 'no' : '';
+ },
+ done: () => {},
+ }, localInput(draft));
+ if (fail) await expect(run).rejects.toThrow('revision failed');
+ else await run;
+ const fixer = calls.indexOf('fixer');
+ expect(fixer).toBeGreaterThan(0);
+ expect(calls[fixer + 1]).toBe('npm test');
+ if (fail) expect(calls).not.toContain('git push');
+ else expect(calls[fixer + 2]).toBe('git push');
+ });
+
+ it('generates valid local source for every preset with an explicit runtime limit', () => {
+ for (const workflow of ['traditional', 'prototype', 'simple'] as const) {
+ const source = factorySource({ ...draft, workflow }, 'local');
+ const file = ts.createSourceFile('local.flow.ts', source, ts.ScriptTarget.ES2022, true);
+ expect((file as unknown as { parseDiagnostics: unknown[] }).parseDiagnostics).toEqual([]);
+ expect(source).toContain('wallclock: "1h"');
+ expect(source).not.toContain('$8/run');
+ expect(source).not.toContain('pr merge');
+ }
+ });
+});
diff --git a/web/lib/test/flow-onboarding.test.ts b/web/lib/test/flow-onboarding.test.ts
new file mode 100644
index 00000000..a6f5d345
--- /dev/null
+++ b/web/lib/test/flow-onboarding.test.ts
@@ -0,0 +1,294 @@
+import { describe, expect, it } from 'vitest';
+import ts from 'typescript';
+import { cloudConnectionsHref, DEFAULT_FACTORY, factorySource, readFactoryDraft, canContinue, primaryAgent, onboardingPath, accessibleOnboardingStep, type FactoryDraft } from '../flow-onboarding';
+
+const matchingIssue = { source: 'github', title: 'Fix login', body: 'Login fails', labels: ['ready', 'bug'], repository: 'acme/app' };
+
+const completed: FactoryDraft = { version: 4, sources: ['github'], sourceSettings: { github: { repository: 'acme/app', labels: 'ready, bug' } }, agents: ['claude', 'codex'], otherAgent: '', task: 'Add a test', workflow: 'traditional', step: 3 };
+
+async function runFactory(clean: boolean[], _approved = true, issue = matchingIssue, draft = completed) {
+ const calls: string[] = [];
+ let finish = '';
+ let index = 0;
+ const source = factorySource(draft).replace('import { flow } from "@relayflows/surface";', '');
+ const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } });
+ const exports: { default?: (ctx: unknown, input: unknown) => Promise } = {};
+ new Function('exports', 'flow', compiled.outputText)(exports, (_name: string, _options: unknown, body: unknown) => body);
+ await exports.default!({
+ agent: async (name: string, options: { cli: string; cwd?: string }) => { calls.push(`${name}:${options.cli}`); if (name.startsWith('prototype-')) { await Promise.resolve(); calls.push('finished:' + name + ':' + options.cwd); } },
+ run: async (command: string) => { calls.push(command); return command.startsWith('test -f') ? (clean[index++] ? 'yes' : 'no') : command.startsWith('mktemp') ? '/tmp/relay-prototypes.test' : command === 'git rev-parse HEAD' ? 'abc123' : ''; },
+ human: async () => { throw new Error('Interactive human approval is unsupported'); },
+ done: (reason: string) => { finish = reason; },
+ }, { issue, approver: 'owner' });
+ return { calls, finish };
+}
+
+describe('software factory onboarding', () => {
+ it('gives the introduction and final handoff distinct routes', () => {
+ expect(onboardingPath(-1)).toBe('/flows/onboarding');
+ expect(onboardingPath(0)).toBe('/flows/onboarding/sources');
+ expect(onboardingPath(1)).toBe('/flows/onboarding/agents');
+ expect(onboardingPath(3)).toBe('/flows/onboarding/connections');
+ });
+
+ it('guards direct links and requires an explicit workflow choice', () => {
+ expect(accessibleOnboardingStep(DEFAULT_FACTORY, 3)).toBe(0);
+ expect(accessibleOnboardingStep({ ...completed, agents: [] }, 3)).toBe(1);
+ expect(accessibleOnboardingStep({ ...completed, task: '', step: 0 }, 3)).toBe(3);
+ expect(DEFAULT_FACTORY.workflow).toBeNull();
+ const unanswered = { ...completed, workflow: null, step: 2 };
+ expect(canContinue(unanswered)).toBe(false);
+ expect(accessibleOnboardingStep(unanswered, 3)).toBe(2);
+ expect(readFactoryDraft(JSON.stringify({ ...unanswered, step: 3 }))?.step).toBe(2);
+ expect(factorySource(unanswered)).not.toContain('f.agent(');
+ expect(factorySource(unanswered)).not.toContain('f.human(');
+ expect(() => cloudConnectionsHref(unanswered, 'id')).toThrow('Choose a workflow');
+ expect(readFactoryDraft(JSON.stringify({ ...unanswered, workflow: 'prototype' }))?.workflow).toBe('prototype');
+ });
+
+ it('rejects corrupt and unsupported persisted drafts', () => {
+ for (const raw of [null, '{', '{}', '{"version":1,"agent":"shell","rounds":3}', '{"version":1,"agent":"claude","rounds":999}']) {
+ expect(readFactoryDraft(raw)).toBeNull();
+ }
+ expect(readFactoryDraft(JSON.stringify(DEFAULT_FACTORY))).toEqual(DEFAULT_FACTORY);
+ });
+
+ it('reveals the full preset at the task step', () => {
+ expect(factorySource(DEFAULT_FACTORY)).not.toContain('f.agent');
+ expect(factorySource({ ...completed, step: 1 })).not.toContain('implementer');
+ expect(factorySource({ ...completed, step: 2 })).toContain('return f.done("needs_human")');
+ });
+
+ it('lets upcoming-only selections continue using a supported example', () => {
+ const draft: FactoryDraft = { ...DEFAULT_FACTORY, sources: ['github'], agents: ['windsurf', 'gemini'], step: 1 };
+ expect(canContinue(draft)).toBe(true);
+ expect(primaryAgent(draft)).toBe('claude');
+ expect(factorySource(draft)).not.toContain('cli: "cursor"');
+ expect(readFactoryDraft(JSON.stringify(draft))?.agents).toEqual(['windsurf', 'gemini']);
+ });
+
+ it.each(['cursor', 'opencode'] as const)('uses %s throughout a workflow without falling back to Claude', async agent => {
+ const draft = { ...completed, agents: [agent] };
+ expect(primaryAgent(draft)).toBe(agent);
+ expect(factorySource(draft)).toContain(`const builder = "${agent}"`);
+ const { calls } = await runFactory([true, true], true, matchingIssue, draft);
+ expect(calls).toContain(`planner:${agent}`);
+ expect(calls).toContain(`plan-reviewer:${agent}`);
+ expect(calls).toContain(`implementer:${agent}`);
+ expect(calls).toContain(`adversary-2:${agent}`);
+ expect(calls.some(call => call.endsWith(':claude'))).toBe(false);
+ });
+
+ it('assigns Cursor and OpenCode distinct prototype and review roles', async () => {
+ const draft: FactoryDraft = { ...completed, agents: ['cursor', 'opencode'], workflow: 'prototype' };
+ const { calls } = await runFactory([true], true, matchingIssue, draft);
+ expect(calls).toContain('prototype-1:cursor');
+ expect(calls).toContain('prototype-2:opencode');
+ expect(calls).toContain('prototype-3:cursor');
+ expect(calls).toContain('comparator:opencode');
+ expect(calls).toContain('implementer:cursor');
+ });
+
+ it('restores incomplete drafts to the first unanswered question', () => {
+ expect(readFactoryDraft(JSON.stringify({ ...completed, agents: [] }))?.step).toBe(1);
+ expect(readFactoryDraft(JSON.stringify({ ...completed, agents: ['unknown'] }))).toBeNull();
+ expect(canContinue(DEFAULT_FACTORY)).toBe(false);
+ expect(canContinue({ ...completed, step: 2, task: ' ' })).toBe(true);
+ });
+
+ it('remembers Pi and write-in preferences without using unsupported CLIs', () => {
+ const draft: FactoryDraft = { ...completed, agents: ['pi'], otherAgent: 'My custom agent', step: 1 };
+ expect(readFactoryDraft(JSON.stringify(draft))).toEqual(draft);
+ expect(canContinue(draft)).toBe(true);
+ const writeInOnly = { ...draft, agents: [] };
+ expect(canContinue(writeInOnly)).toBe(true);
+ expect(factorySource(writeInOnly)).toContain('const builder = "claude"');
+ expect(factorySource(writeInOnly)).not.toContain('My custom agent');
+ expect(factorySource(draft)).not.toContain('cli: "pi"');
+ expect(canContinue({ ...writeInOnly, otherAgent: ' ' })).toBe(false);
+ const unchecked = { ...writeInOnly, otherAgentSelected: false };
+ expect(canContinue(unchecked)).toBe(false);
+ expect(factorySource(unchecked)).not.toContain('const builder');
+ expect(readFactoryDraft(JSON.stringify(unchecked))?.otherAgentSelected).toBe(false);
+ expect(canContinue({ ...unchecked, otherAgentSelected: true })).toBe(true);
+ });
+
+ it('restores older drafts and validates the write-in name', () => {
+ const { otherAgent, ...olderDraft } = completed;
+ expect(readFactoryDraft(JSON.stringify(olderDraft))?.otherAgent).toBe('');
+ for (const value of [null, 7, 'x'.repeat(101)]) {
+ expect(readFactoryDraft(JSON.stringify({ ...completed, otherAgent: value }))).toBeNull();
+ }
+ });
+
+ it('escapes task text and keeps partial files syntactically valid', () => {
+ for (let step = 0; step <= 3; step++) {
+ const source = factorySource({ ...completed, task: 'Fix "login"\\nwith `quotes`', step });
+ const file = ts.createSourceFile('draft.ts', source, ts.ScriptTarget.ES2022, true);
+ expect((file as unknown as { parseDiagnostics: unknown[] }).parseDiagnostics).toEqual([]);
+ }
+ });
+
+ it('migrates old drafts without losing previous answers', () => {
+ const { sources, sourceSettings, ...oldDraft } = completed;
+ const migrated = readFactoryDraft(JSON.stringify({ ...oldDraft, version: 2, step: 5 }));
+ expect(migrated?.agents).toEqual(completed.agents);
+ expect(migrated?.task).toBe(completed.task);
+ expect(migrated?.sources).toEqual([]);
+ expect(migrated?.step).toBe(0);
+ });
+
+ it('keeps source filters through a storage round trip and rejects invalid settings', () => {
+ expect(readFactoryDraft(JSON.stringify(completed))).toEqual(completed);
+ expect(readFactoryDraft(JSON.stringify({ ...completed, sourceSettings: { github: { channel: 'bad' } } }))).toBeNull();
+ expect(readFactoryDraft(JSON.stringify({ ...completed, sources: ['unknown'] }))).toBeNull();
+ expect(readFactoryDraft(JSON.stringify({ ...completed, sourceSettings: { slack: { mentioned: 'true' } } }))).toBeNull();
+ });
+
+ it('never runs agents for tickets outside the chosen filters', async () => {
+ for (const issue of [
+ { ...matchingIssue, source: 'linear' },
+ { ...matchingIssue, repository: 'acme/other' },
+ { ...matchingIssue, labels: ['ready'] },
+ ]) {
+ const result = await runFactory([true], true, issue);
+ expect(result.calls).toEqual([]);
+ expect(result.finish).toBe('canceled');
+ }
+ expect((await runFactory([true, true], true, { ...matchingIssue, labels: ['BUG', ' Ready '] })).finish).toBe('needs_human');
+ });
+
+ it('carries the complete flow to Cloud without putting its contents in a query', () => {
+ const draft: FactoryDraft = { ...DEFAULT_FACTORY, sources: ['github'], sourceSettings: { github: { repository: 'org/repo', labels: 'ready' } }, agents: ['codex'], task: 'Keep naïve input & labels', workflow: 'traditional', step: 3 };
+ const url = new URL(cloudConnectionsHref(draft, '00000000-0000-4000-8000-000000000001'));
+ expect(url.origin).toBe('https://agentrelay.com');
+ expect(url.pathname).toBe('/cloud/flows/import');
+ expect(url.search).toBe('');
+ const payload = JSON.parse(decodeURIComponent(url.hash.slice(1)));
+ expect(payload.source).toBe(factorySource(draft));
+ expect(payload.sourceSettings).toEqual(draft.sourceSettings);
+ expect(payload.agents).toEqual(['codex']);
+ expect(payload.task).toBe(draft.task);
+ expect(payload.handoffId).toBe('00000000-0000-4000-8000-000000000001');
+ });
+
+ it('reads a Markdown task without an external source connection and quotes the path', async () => {
+ const path = "docs/team's $(touch nope).md";
+ const draft: FactoryDraft = { ...DEFAULT_FACTORY, sources: ['markdown', 'github'],
+ sourceSettings: { markdown: { path } }, agents: ['claude'], task: 'Implement this task', workflow: 'simple', step: 2 };
+ expect(readFactoryDraft(JSON.stringify(draft))?.sources).toEqual(['markdown', 'github']);
+ const compiled = ts.transpileModule(factorySource(draft).replace('import { flow } from "@relayflows/surface";', ''), {
+ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
+ });
+ const exports: { default?: (ctx: unknown, input: unknown) => Promise } = {};
+ new Function('exports', 'flow', compiled.outputText)(exports, (_name: string, _options: unknown, body: unknown) => body);
+ const commands: string[] = [];
+ const tasks: string[] = [];
+ const ctx = {
+ run: async (command: string) => { commands.push(command); return '# Fix the login bug'; },
+ agent: async (_name: string, options: { task: string }) => { tasks.push(options.task); },
+ done: () => {},
+ };
+ await exports.default!(ctx, { approver: 'owner' });
+ expect(commands[0]).toBe("cat -- 'docs/team'\\''s $(touch nope).md'");
+ expect(tasks[0]).toContain('# Fix the login bug');
+ commands.length = 0;
+ tasks.length = 0;
+ await exports.default!(ctx, { issue: matchingIssue, approver: 'owner' });
+ expect(commands.some(command => command.startsWith('cat --'))).toBe(false);
+ expect(tasks[0]).toContain(matchingIssue.body);
+ });
+
+ it('revises a failed review, retests, then asks for human approval', async () => {
+ const { calls, finish } = await runFactory([false, true]);
+ expect(calls.filter(call => call.startsWith('adversary-'))).toHaveLength(2);
+ expect(calls).toContain('fixer:claude');
+ expect(calls.filter(call => call === 'npm test')).toHaveLength(2);
+ expect(calls).not.toContain('human');
+ expect(finish).toBe('needs_human');
+ });
+
+ it('tests and pushes the branch before opening its pull request', async () => {
+ const { calls } = await runFactory([true]);
+ const push = calls.indexOf('git push --set-upstream origin HEAD');
+ const create = calls.findIndex(call => call.startsWith('gh pr create'));
+ expect(push).toBeGreaterThan(calls.indexOf('npm test'));
+ expect(create).toBeGreaterThan(push);
+ expect(calls.indexOf('adversary-1:codex')).toBeGreaterThan(create);
+ });
+
+ it('never reaches approval if all reviews fail', async () => {
+ const { calls, finish } = await runFactory([false, false, false]);
+ expect(calls.filter(call => call.startsWith('adversary-'))).toHaveLength(2);
+ expect(calls).not.toContain('human');
+ expect(finish).toBe('step_failed');
+ });
+
+ it('hands every preset to a person without calling an unsupported interactive gate or merging', async () => {
+ for (const workflow of ['traditional', 'prototype', 'simple'] as const) {
+ const { calls, finish } = await runFactory([true, true], true, matchingIssue, { ...completed, workflow });
+ expect(finish).toBe('needs_human');
+ expect(calls.some(call => call.includes('pr merge'))).toBe(false);
+ expect(factorySource({ ...completed, workflow })).not.toContain('f.human(');
+ expect(calls).toContain('npm test');
+ expect(calls.some(call => call.startsWith('gh pr create'))).toBe(true);
+ }
+ });
+
+ it('always runs both reviews after the plan and implementation', async () => {
+ const { calls, finish } = await runFactory([true, true]);
+ expect(calls.filter(call => /^(planner|plan-reviewer|implementer|adversary-)/.test(call))).toEqual([
+ 'planner:claude', 'plan-reviewer:codex', 'implementer:claude', 'adversary-1:codex', 'adversary-2:codex',
+ ]);
+ expect(calls).not.toContain('fixer:claude');
+ expect(finish).toBe('needs_human');
+ });
+
+ it('waits for three isolated prototypes before comparing, building and reviewing', async () => {
+ const { calls, finish } = await runFactory([true], true, matchingIssue, { ...completed, workflow: 'prototype' });
+ const finished = calls.filter(call => call.startsWith('finished:'));
+ expect(finished).toHaveLength(3);
+ expect(new Set(finished.map(call => call.split(':').at(-1))).size).toBe(3);
+ expect(calls.filter(call => call.startsWith('git worktree add --detach'))).toHaveLength(3);
+ for (const call of finished) expect(calls.indexOf(call)).toBeLessThan(calls.indexOf('comparator:codex'));
+ expect(calls.indexOf('comparator:codex')).toBeLessThan(calls.indexOf('implementer:claude'));
+ expect(calls.some(call => call.startsWith('synthesizer:'))).toBe(false);
+ const source = factorySource({ ...completed, workflow: 'prototype' });
+ expect(source).not.toContain('best-plan.md');
+ expect(source).toContain('Read comparison.md and inspect the prototype implementations');
+ expect(calls).toContain('adversary-1:codex');
+ expect(calls).not.toContain('human');
+ expect(finish).toBe('needs_human');
+ });
+
+ it('rotates the selected agents across prototypes in selection order', async () => {
+ for (const [agents, expected] of [
+ [['claude', 'codex'], ['claude', 'codex', 'claude']],
+ [['codex', 'claude'], ['codex', 'claude', 'codex']],
+ [['codex'], ['codex', 'codex', 'codex']],
+ [['claude'], ['claude', 'claude', 'claude']],
+ ] as const) {
+ const { calls } = await runFactory([true], true, matchingIssue, { ...completed, workflow: 'prototype', agents: [...agents] });
+ expect(calls.filter(call => call.startsWith('prototype-'))).toEqual(expected.map((agent, i) => `prototype-${i + 1}:${agent}`));
+ }
+ });
+
+ it('simple skips agent reviews but still hands off for human approval and all presets persist', async () => {
+ const { calls, finish } = await runFactory([], true, matchingIssue, { ...completed, workflow: 'simple' });
+ expect(calls.filter(call => /:(claude|codex)$/.test(call))).toEqual(['implementer:claude']);
+ expect(calls).not.toContain('human');
+ expect(finish).toBe('needs_human');
+ for (const workflow of ['traditional', 'prototype', 'simple'] as const) {
+ const draft = { ...completed, workflow, task: 'Quotes " and backticks `' };
+ expect(readFactoryDraft(JSON.stringify(draft))).toEqual(draft);
+ const file = ts.createSourceFile('draft.ts', factorySource(draft), ts.ScriptTarget.ES2022, true);
+ expect((file as unknown as { parseDiagnostics: unknown[] }).parseDiagnostics).toEqual([]);
+ }
+ });
+
+ it('migrates v3 preferences without preselecting a workflow', () => {
+ expect(readFactoryDraft(JSON.stringify({ ...completed, version: 3, step: 6, reviewer: 'codex', rounds: 5, approval: true })))
+ .toEqual({ ...completed, workflow: null, step: 2 });
+ expect(readFactoryDraft(JSON.stringify({ ...completed, workflow: 'unknown' }))).toBeNull();
+ });
+});
diff --git a/web/lib/test/flow-sources.test.ts b/web/lib/test/flow-sources.test.ts
new file mode 100644
index 00000000..a083e383
--- /dev/null
+++ b/web/lib/test/flow-sources.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest';
+import ts from 'typescript';
+import { issueSourceCode, type IssueSourceId, type SourcePreferences } from '../flow-sources';
+
+function matcher(sources: IssueSourceId[], settings: SourcePreferences) {
+ const code = ts.transpileModule(issueSourceCode(sources, settings), {
+ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
+ }).outputText;
+ const exports: { matchesIssue?: (issue: unknown) => boolean } = {};
+ new Function('exports', code)(exports);
+ return exports.matchesIssue!;
+}
+const issue = { source: 'linear', title: 'Fix login', body: 'Users cannot sign in.', labels: ['ready'] };
+
+describe('generated issue source filters', () => {
+ it('combines source-specific filters and allows any selected source', () => {
+ const matches = matcher(['linear', 'shortcut', 'jira'], {
+ linear: { team: 'Engineering', project: 'Web', labels: 'ready' },
+ shortcut: { workspace: 'acme', project: 'App', labels: 'bug, ready' },
+ jira: { project: 'ENG', labels: 'ready' },
+ });
+ expect(matches({ ...issue, team: 'Engineering', project: 'Web' })).toBe(true);
+ expect(matches({ ...issue, team: 'Design', project: 'Web' })).toBe(false);
+ expect(matches({ ...issue, source: 'shortcut', workspace: 'acme', project: 'App', labels: ['ready', 'bug'] })).toBe(true);
+ expect(matches({ ...issue, source: 'shortcut', workspace: 'other', project: 'App', labels: ['ready', 'bug'] })).toBe(false);
+ expect(matches({ ...issue, source: 'jira', project: 'ENG' })).toBe(true);
+ expect(matches({ ...issue, source: 'github' })).toBe(false);
+ });
+
+ it('requires Slack channel, keyword and mention when configured', () => {
+ const matches = matcher(['slack'], { slack: { channel: '#requests', contains: 'please fix', mentioned: true } });
+ const message = { ...issue, source: 'slack', channel: 'requests', body: 'Please fix this bug.', mentioned: true };
+ expect(matches(message)).toBe(true);
+ expect(matches({ ...message, channel: 'general' })).toBe(false);
+ expect(matches({ ...message, mentioned: false })).toBe(false);
+ expect(matches({ ...message, body: 'Just chatting' })).toBe(false);
+ });
+
+ it('allows blank filters, escapes input, and ignores deselected source preferences', () => {
+ const matches = matcher(['github'], { github: { labels: ' , ' }, slack: { contains: 'old setting' } });
+ expect(matches({ ...issue, source: 'github' })).toBe(true);
+ expect(matches({ ...issue, source: 'slack' })).toBe(false);
+ const quoted = 'a "quoted" \\ value';
+ expect(matcher(['linear'], { linear: { project: quoted } })({ ...issue, project: quoted })).toBe(true);
+ });
+
+ it('rejects malformed events and inherited source names', () => {
+ const matches = matcher(['github'], {});
+ for (const value of [null, {}, { ...issue, source: 'toString' }, { ...issue, source: 'github', labels: [3] }]) {
+ expect(matches(value)).toBe(false);
+ }
+ });
+
+ it('uses tasks.md by default and matches Markdown file paths exactly', () => {
+ const matches = matcher(['markdown'], {});
+ expect(matches({ ...issue, source: 'markdown', path: 'tasks.md' })).toBe(true);
+ expect(matches({ ...issue, source: 'markdown', path: 'Tasks.md' })).toBe(false);
+ expect(matches({ ...issue, source: 'github', path: 'tasks.md' })).toBe(false);
+ });
+});
diff --git a/web/lib/test/site-analytics.test.ts b/web/lib/test/site-analytics.test.ts
index 26282631..56bb5612 100644
--- a/web/lib/test/site-analytics.test.ts
+++ b/web/lib/test/site-analytics.test.ts
@@ -3,6 +3,11 @@ import { describe, expect, it } from 'vitest';
import { getWebsiteAnalyticsPage } from '../site-analytics';
describe('getWebsiteAnalyticsPage', () => {
+ it('tracks the landing page and every onboarding route', () => {
+ for (const pathname of ['/flows', '/flows/onboarding', ...['sources', 'agents', 'task', 'connections'].map(stage => `/flows/onboarding/${stage}`)]) {
+ expect(getWebsiteAnalyticsPage(pathname)).toEqual({ pageGroup: 'flows', pathname });
+ }
+ });
it('tracks docs routes', () => {
expect(getWebsiteAnalyticsPage('/docs/quickstart')).toEqual({
pageGroup: 'docs',
diff --git a/web/package.json b/web/package.json
index a056d972..39579092 100644
--- a/web/package.json
+++ b/web/package.json
@@ -24,6 +24,7 @@
"@tailwindcss/postcss": "^4.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "fflate": "0.4.8",
"gray-matter": "^4.0.3",
"lucide-react": "^1.0.1",
"next": "16.2.10",