Slice 1: Conversation to Proposed Work with MiMo V2.5 (#19)

This commit is contained in:
2026-07-27 09:32:29 +00:00
116 changed files with 10801 additions and 2543 deletions

View File

@@ -25,12 +25,12 @@ DAEMON_COMMAND_LEASE_MS=60000
FLUE_DB_TOKEN=replace-with-a-long-random-token FLUE_DB_TOKEN=replace-with-a-long-random-token
# Agent model provider # Agent model provider
AGENT_MODEL_PROVIDER=cheaptricks AGENT_MODEL_PROVIDER=xiaomi
AGENT_MODEL_NAME=glm-5.2 AGENT_MODEL_NAME=mimo-v2.5
AGENT_MODEL_API=openai-completions AGENT_MODEL_API=openai-completions
AGENT_MODEL_BASE_URL=https://ai.example.com/v1 AGENT_MODEL_BASE_URL=https://ai.example.com/v1
AGENT_MODEL_API_KEY=replace-with-provider-api-key AGENT_MODEL_API_KEY=replace-with-provider-api-key
AGENT_MODEL_CONTEXT_WINDOW=262000 AGENT_MODEL_CONTEXT_WINDOW=1048576
AGENT_MODEL_MAX_TOKENS=131072 AGENT_MODEL_MAX_TOKENS=131072
# Self-hosted git (Gitea) — canonical repository host and API token # Self-hosted git (Gitea) — canonical repository host and API token

View File

@@ -18,7 +18,7 @@
"@rivet-dev/agentos": "catalog:", "@rivet-dev/agentos": "catalog:",
"convex": "catalog:", "convex": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"rivetkit": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0" "rivetkit": "2.3.7"
}, },
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",

View File

@@ -2,26 +2,27 @@ import { env } from "@code/env/server";
import { Console, Effect, FiberSet, Result, Stream } from "effect"; import { Console, Effect, FiberSet, Result, Stream } from "effect";
import { makeAgentOsRuntime } from "./agent-os"; import { makeAgentOsRuntime } from "./agent-os";
import { ConvexControlPlane, type DaemonCommand } from "./convex"; import { ConvexControlPlane } from "./convex";
import type { DaemonCommand } from "./convex";
const errorMessage = (cause: unknown) => const errorMessage = (cause: unknown) =>
cause instanceof Error ? cause.message : String(cause); cause instanceof Error ? cause.message : String(cause);
export const runDaemon = Effect.scoped( export const runDaemon = Effect.scoped(
Effect.gen(function* () { Effect.gen(function* runDaemon() {
const controlPlane = yield* ConvexControlPlane; const controlPlane = yield* ConvexControlPlane;
const agentOs = yield* makeAgentOsRuntime; const agentOs = yield* makeAgentOsRuntime;
const sessionId = crypto.randomUUID(); const sessionId = crypto.randomUUID();
const hostname = yield* Effect.promise(() => import("node:os")).pipe( const hostname = yield* Effect.promise(() => import("node:os")).pipe(
Effect.map((os) => os.hostname()) Effect.map((os) => os.hostname())
); );
const fibers = yield* FiberSet.make<void>(); const fibers = yield* FiberSet.make();
yield* controlPlane.connect({ yield* controlPlane.connect({
sessionId, architecture: process.arch,
hostname, hostname,
platform: process.platform, platform: process.platform,
architecture: process.arch, sessionId,
}); });
yield* Console.log(`daemon ${env.DAEMON_ID} connected as ${sessionId}`); yield* Console.log(`daemon ${env.DAEMON_ID} connected as ${sessionId}`);
@@ -40,9 +41,8 @@ export const runDaemon = Effect.scoped(
Effect.forkScoped Effect.forkScoped
); );
const processCommand = Effect.fn("Daemon.processCommand")(function* ( const processCommand = Effect.fn("Daemon.processCommand")(
candidate: DaemonCommand function* processCommand(candidate: DaemonCommand) {
) {
const command = yield* controlPlane.claim(candidate._id, sessionId); const command = yield* controlPlane.claim(candidate._id, sessionId);
if (!command) { if (!command) {
return; return;
@@ -53,12 +53,16 @@ export const runDaemon = Effect.scoped(
} }
yield* controlPlane.recordEvent({ yield* controlPlane.recordEvent({
commandId: command._id, commandId: command._id,
data: { actorKey: command.actorKey, method: command.method },
kind: "command.started", kind: "command.started",
data: { method: command.method, actorKey: command.actorKey },
}); });
const result = yield* agentOs.execute(command).pipe(Effect.result); const result = yield* agentOs.execute(command).pipe(Effect.result);
if (Result.isSuccess(result)) { if (Result.isSuccess(result)) {
yield* controlPlane.succeed(command._id, sessionId, result.success); yield* controlPlane.succeed(
command._id,
sessionId,
result.success ?? null
);
return; return;
} }
yield* controlPlane.fail( yield* controlPlane.fail(
@@ -66,7 +70,8 @@ export const runDaemon = Effect.scoped(
sessionId, sessionId,
errorMessage(result.failure) errorMessage(result.failure)
); );
}); }
);
yield* controlPlane.commands.pipe( yield* controlPlane.commands.pipe(
Stream.runForEach((commands) => Stream.runForEach((commands) =>

View File

@@ -15,6 +15,7 @@
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@code/primitives": "workspace:*", "@code/primitives": "workspace:*",
"@code/ui": "workspace:*", "@code/ui": "workspace:*",
"@code/work-os": "workspace:*",
"@flue/react": "1.0.0-beta.9", "@flue/react": "1.0.0-beta.9",
"@flue/sdk": "1.0.0-beta.9", "@flue/sdk": "1.0.0-beta.9",
"@react-router/fs-routes": "^8.1.0", "@react-router/fs-routes": "^8.1.0",

View File

@@ -0,0 +1,162 @@
import { useConvexAccessToken } from "@code/auth/web";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@code/ui/components/attachment";
import type { FlueConversationPart } from "@flue/react";
import { ImageIcon, X } from "lucide-react";
import { useEffect, useState } from "react";
import type { PendingChatImage } from "@/lib/chat/attachments";
type FilePart = Extract<FlueConversationPart, { type: "file" }>;
const isDirectlyRenderableUrl = (url: string): boolean =>
url.startsWith("blob:") || url.startsWith("data:");
const useAuthenticatedImageSource = (url?: string): string | undefined => {
const resolveAccessToken = useConvexAccessToken();
const [loaded, setLoaded] = useState<{
readonly source: string;
readonly url: string;
}>();
useEffect(() => {
if (!url || isDirectlyRenderableUrl(url)) {
return;
}
let active = true;
let objectUrl: string | undefined;
const load = async (): Promise<void> => {
try {
const accessToken = await resolveAccessToken();
const response = await fetch(url, {
headers: accessToken
? { authorization: `Bearer ${accessToken}` }
: undefined,
});
if (!response.ok) {
return;
}
objectUrl = URL.createObjectURL(await response.blob());
if (active) {
setLoaded({ source: objectUrl, url });
}
} catch {
// The attachment remains represented by its filename and media type.
}
};
void load();
return () => {
active = false;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [resolveAccessToken, url]);
if (url && isDirectlyRenderableUrl(url)) {
return url;
}
return loaded && loaded.url === url ? loaded.source : undefined;
};
const ImagePreview = ({
alt,
mediaType,
url,
}: {
readonly alt: string;
readonly mediaType: string;
readonly url?: string;
}) => {
const source = useAuthenticatedImageSource(url);
return (
<AttachmentMedia
variant={mediaType.startsWith("image/") ? "image" : "icon"}
>
{source && mediaType.startsWith("image/") ? (
<img alt={alt} className="size-full object-cover" src={source} />
) : (
<ImageIcon className="size-4" />
)}
</AttachmentMedia>
);
};
export const PendingChatAttachments = ({
images,
onRemove,
}: {
readonly images: readonly PendingChatImage[];
readonly onRemove: (id: string) => void;
}) => (
<AttachmentGroup className="max-w-full gap-2 pb-2">
{images.map((image) => (
<Attachment
className="w-24 border-[#d7d3c7] bg-[#fffefa] text-[#20201d]"
key={image.id}
orientation="vertical"
size="sm"
>
<ImagePreview
alt={image.file.name}
mediaType={image.file.type}
url={image.previewUrl}
/>
<AttachmentContent>
<AttachmentTitle>{image.file.name}</AttachmentTitle>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={`Remove ${image.file.name}`}
className="bg-white/90 hover:bg-white"
onClick={() => onRemove(image.id)}
>
<X className="size-3.5" />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
</AttachmentGroup>
);
export const MessageAttachments = ({
parts,
}: {
readonly parts: readonly FilePart[];
}) => {
if (parts.length === 0) {
return null;
}
return (
<AttachmentGroup className="mb-2 max-w-full gap-2">
{parts.map((part, index) => {
const title = part.filename ?? `Image ${index + 1}`;
return (
<Attachment
className="min-w-0 max-w-48 border-[#d7d3c7] bg-[#fffefa] text-[#20201d]"
key={part.id ?? `${part.mediaType}:${index}`}
size="sm"
>
<ImagePreview
alt={title}
mediaType={part.mediaType}
url={part.url}
/>
<AttachmentContent>
<AttachmentTitle>{title}</AttachmentTitle>
<AttachmentDescription>{part.mediaType}</AttachmentDescription>
</AttachmentContent>
</Attachment>
);
})}
</AttachmentGroup>
);
};

View File

@@ -7,26 +7,63 @@ import {
MobileChatBubble, MobileChatBubble,
MobileChatMessage, MobileChatMessage,
} from "@code/ui/components/mobile-chat"; } from "@code/ui/components/mobile-chat";
import type { ReactNode } from "react"; import type { FlueConversationMessage } from "@flue/react";
import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms"; import {
import type { ChatMessageProps } from "@/lib/chat/types"; getMessageText,
getReasoningText,
hasToolActivity,
isReasoningStreaming,
isMessageStreaming,
} from "@/lib/chat/transforms";
import type {
AssistantResponseState,
ChatMessageProps,
} from "@/lib/chat/types";
import { AssistantIdentity } from "./assistant-identity"; import { AssistantIdentity } from "./assistant-identity";
import { MessageAttachments } from "./chat-attachments";
import { ChatToolCall } from "./chat-tool-call"; import { ChatToolCall } from "./chat-tool-call";
export const ChatMessage = ({ message }: ChatMessageProps) => { const ReasoningTrace = ({
const isUser = message.role === "user"; isStreaming,
const isStreaming = isMessageStreaming(message); text,
const text = getMessageText(message); }: {
readonly isStreaming: boolean;
if (message.parts.length === 0 && !isStreaming) { readonly text: string;
}) => {
if (!text) {
return null; return null;
} }
return (
<details
className="mb-3 border-l-2 border-[#b9b5aa] pl-3 text-[#5f5d55]"
open={isStreaming ? true : undefined}
>
<summary className="cursor-pointer select-none text-xs font-medium text-[#69675e]">
{isStreaming ? "Thinking live" : "Thinking trace"}
</summary>
<MessageResponse
className="chat-reasoning mt-2 min-w-0 text-xs leading-5"
isAnimating={isStreaming}
>
{text}
</MessageResponse>
</details>
);
};
let content: ReactNode = text; const AssistantText = ({
if (!isUser) { hasReasoning,
content = text ? ( isStreaming,
text,
}: {
readonly hasReasoning: boolean;
readonly isStreaming: boolean;
readonly text: string;
}) => {
if (text) {
return (
<MessageResponse <MessageResponse
caret={isStreaming ? "block" : undefined} caret={isStreaming ? "block" : undefined}
className="chat-markdown min-w-0" className="chat-markdown min-w-0"
@@ -34,34 +71,103 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
> >
{text} {text}
</MessageResponse> </MessageResponse>
) : ( );
}
if (hasReasoning) {
return null;
}
return (
<div className="thinking-line" aria-label="Zopu is preparing a response"> <div className="thinking-line" aria-label="Zopu is preparing a response">
<span /> <span />
<span /> <span />
<span /> <span />
</div> </div>
); );
};
const assistantState = (
isStreaming: boolean,
reasoningStreaming: boolean,
text: string
): AssistantResponseState | undefined => {
if (reasoningStreaming && !text) {
return "thinking";
}
return isStreaming ? "writing" : undefined;
};
const ToolActivity = ({
hidden,
message,
}: {
readonly hidden: boolean;
readonly message: FlueConversationMessage;
}) => {
if (hidden) {
return null;
}
return message.parts.map((part) =>
part.type === "dynamic-tool" ? (
<ChatToolCall key={part.toolCallId} part={part} />
) : null
);
};
export const ChatMessage = ({
hideToolActivity = false,
message,
}: ChatMessageProps) => {
const isUser = message.role === "user";
const isStreaming = isMessageStreaming(message);
const text = getMessageText(message);
const reasoning = getReasoningText(message);
const reasoningStreaming = isReasoningStreaming(message);
const fileParts = message.parts.filter((part) => part.type === "file");
if (
hideToolActivity &&
!isUser &&
hasToolActivity(message) &&
!text &&
!reasoning
) {
return null;
}
if (message.parts.length === 0 && !isStreaming) {
return null;
} }
const sender = isUser ? "user" : "assistant"; const sender = isUser ? "user" : "assistant";
const content = isUser ? (
text
) : (
<AssistantText
hasReasoning={Boolean(reasoning)}
isStreaming={isStreaming}
text={text}
/>
);
const identityState = assistantState(isStreaming, reasoningStreaming, text);
return ( return (
<Message className="max-w-full gap-0" from={message.role}> <Message className="max-w-full gap-0" from={message.role}>
<MessageContent className="w-full max-w-none gap-0 overflow-visible rounded-none bg-transparent p-0 group-[.is-user]:rounded-none group-[.is-user]:bg-transparent group-[.is-user]:p-0"> <MessageContent className="w-full max-w-none gap-0 overflow-visible rounded-none bg-transparent p-0 group-[.is-user]:rounded-none group-[.is-user]:bg-transparent group-[.is-user]:p-0">
<MobileChatMessage className="chat-message" sender={sender}> <MobileChatMessage className="chat-message" sender={sender}>
<div className={isUser ? "max-w-full" : "w-full min-w-0"}> <div className={isUser ? "max-w-full" : "w-full min-w-0"}>
{!isUser && ( {isUser ? null : <AssistantIdentity state={identityState} />}
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
)}
<MobileChatBubble <MobileChatBubble
className={isUser ? "whitespace-pre-wrap" : undefined} className={isUser ? "whitespace-pre-wrap" : undefined}
sender={sender} sender={sender}
> >
{message.parts.map((part) => <MessageAttachments parts={fileParts} />
part.type === "dynamic-tool" ? ( {isUser ? null : (
<ChatToolCall key={part.toolCallId} part={part} /> <ReasoningTrace
) : null isStreaming={reasoningStreaming}
text={reasoning}
/>
)} )}
<ToolActivity hidden={hideToolActivity} message={message} />
{content} {content}
</MobileChatBubble> </MobileChatBubble>
</div> </div>

View File

@@ -1,19 +1,29 @@
import { signOutWeb, useWebAuth } from "@code/auth/web";
import type { Id } from "@code/backend/convex/_generated/dataModel"; import type { Id } from "@code/backend/convex/_generated/dataModel";
import { import {
MobileAssistantChatScreen, MobileAssistantChatScreen,
MobileExpandedWorkScreen, MobileExpandedWorkScreen,
MobileHomeScreen, MobileHomeScreen,
MobileWorkChatScreen,
MobileWorkListScreen, MobileWorkListScreen,
MobileWorkStackScreen,
MobileWorkUnitDetailScreen, MobileWorkUnitDetailScreen,
} from "@code/ui/components/mobile-product"; } from "@code/ui/components/mobile-product";
import type { FormEvent } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace"; import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
import { useMobileWorkStack } from "@/hooks/use-mobile-work-stack";
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace"; import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
import { MODEL_ID, MODEL_LABEL } from "@/lib/chat/constants";
import { getMobileStatusMessage } from "@/lib/mobile-workspace/mobile-status-message";
import { getUserInitials } from "@/lib/users/get-user-initials";
export type MobileFlowScreen = export type MobileFlowScreen =
| "assistant-chat" | "assistant-chat"
| "home" | "home"
| "stack-home"
| "work-chat"
| "work-list" | "work-list"
| "work-unit-detail"; | "work-unit-detail";
@@ -24,6 +34,7 @@ interface MobileFlowPageProps {
export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => { export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { workUnitId } = useParams(); const { workUnitId } = useParams();
const auth = useWebAuth();
const projectWorkspace = useMobileProjectWorkspace({ const projectWorkspace = useMobileProjectWorkspace({
selectedWorkUnitId: workUnitId, selectedWorkUnitId: workUnitId,
}); });
@@ -32,21 +43,36 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
projectWorkspace.raiseIssue({ body, title }), projectWorkspace.raiseIssue({ body, title }),
onSend: projectWorkspace.sendMessage, onSend: projectWorkspace.sendMessage,
}); });
const workStack = useMobileWorkStack(projectWorkspace.data.workUnits);
const workPath = "/work"; const workPath = "/work";
const chatPath = "/chat"; const chatPath = "/chat";
const handleRestoreChecked = workStack.restoreChecked;
const handleWorkUnitChecked = workStack.markChecked;
const handleWorkUnitSentBack = workStack.sendToBack;
const user = auth.status === "authenticated" ? auth.user : undefined;
const handleSignOut = async () => {
await signOutWeb();
navigate("/login");
};
const statusMessage = getMobileStatusMessage({
assistantError: projectWorkspace.error?.message,
localStatus: workspace.statusMessage,
});
const handleRepositoryChange =
projectWorkspace.projectWorkspace.setRepository;
const handleOpenUnit = (selectedWorkUnitId?: string) => { const handleOpenUnit = (selectedWorkUnitId?: string) => {
const targetId = const targetId =
selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id; selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id;
if (!targetId) { if (!targetId) {
navigate("/dashboard"); workspace.setProjectManagerOpen(true);
return; return;
} }
if (screen === "work-list" && !workspace.expanded) { if (screen === "work-list" && !workspace.expanded) {
workspace.setExpanded(true); workspace.setExpanded(true);
return; return;
} }
navigate(`/work/${targetId}`); navigate(`/chat/${targetId}`);
}; };
const screenProps = { const screenProps = {
@@ -54,8 +80,11 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
createIssueBody: workspace.issueBody, createIssueBody: workspace.issueBody,
createIssueTitle: workspace.issueTitle, createIssueTitle: workspace.issueTitle,
data: projectWorkspace.data, data: projectWorkspace.data,
onBack: () => navigate(workPath), modelId: MODEL_ID,
modelLabel: MODEL_LABEL,
onBack: () => navigate(chatPath),
onComposerChange: workspace.handleComposerChange, onComposerChange: workspace.handleComposerChange,
onComposerMessageSubmit: workspace.handleComposerMessageSubmit,
onComposerSubmit: workspace.handleComposerSubmit, onComposerSubmit: workspace.handleComposerSubmit,
onCreateBodyChange: workspace.setIssueBody, onCreateBodyChange: workspace.setIssueBody,
onCreateIssue: workspace.handleCreateIssueSubmit, onCreateIssue: workspace.handleCreateIssueSubmit,
@@ -66,16 +95,24 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
) )
: undefined, : undefined,
onCreateTitleChange: workspace.setIssueTitle, onCreateTitleChange: workspace.setIssueTitle,
onManageProjects: () => navigate("/dashboard"), onManageProjects: () => workspace.setProjectManagerOpen(true),
onOpenAssistant: () => navigate(chatPath), onOpenAssistant: () => navigate(chatPath),
onOpenUnit: handleOpenUnit, onOpenUnit: handleOpenUnit,
onProjectManagerClose: () => workspace.setProjectManagerOpen(false),
onProjectSelect: (projectId: string) => onProjectSelect: (projectId: string) =>
projectWorkspace.projectWorkspace.setSelectedProjectId( projectWorkspace.projectWorkspace.setSelectedProjectId(
projectId as Id<"projects"> projectId as Id<"projects">
), ),
onRepositoryChange: projectWorkspace.projectWorkspace.setRepository,
onRepositoryConnect: (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
void projectWorkspace.projectWorkspace.connectRepository();
},
onReviewUnit: (reviewUrl: string) => { onReviewUnit: (reviewUrl: string) => {
window.open(reviewUrl, "_blank", "noopener,noreferrer"); window.open(reviewUrl, "_blank", "noopener,noreferrer");
}, },
onSettingsClose: () => workspace.setSettingsOpen(false),
onSettingsOpen: () => workspace.setSettingsOpen(true),
onStartUnit: (selectedIssueId: string) => { onStartUnit: (selectedIssueId: string) => {
void projectWorkspace.startWorkUnit( void projectWorkspace.startWorkUnit(
selectedIssueId as Id<"projectIssues"> selectedIssueId as Id<"projectIssues">
@@ -83,12 +120,73 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
}, },
onViewWork: () => navigate(workPath), onViewWork: () => navigate(workPath),
pendingAction: projectWorkspace.projectWorkspace.pendingAction, pendingAction: projectWorkspace.projectWorkspace.pendingAction,
statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message, projectManagerOpen: workspace.projectManagerOpen,
repositoryValue: projectWorkspace.projectWorkspace.repository,
settingsOpen: workspace.settingsOpen,
statusMessage,
}; };
if (screen === "assistant-chat") { if (screen === "assistant-chat") {
return <MobileAssistantChatScreen {...screenProps} />; return <MobileAssistantChatScreen {...screenProps} />;
} }
if (screen === "stack-home") {
return (
<MobileWorkStackScreen
checkedCount={workStack.checkedCount}
composerValue={workspace.composerValue}
data={projectWorkspace.data}
modelId={MODEL_ID}
modelLabel={MODEL_LABEL}
onAddProject={() => workspace.setProjectManagerOpen(true)}
onComposerChange={workspace.handleComposerChange}
onComposerSubmit={workspace.handleComposerSubmit}
onOpenUnit={(selectedWorkUnitId) =>
navigate(`/chat/${selectedWorkUnitId}`)
}
onProjectSelect={(projectId) =>
projectWorkspace.projectWorkspace.setSelectedProjectId(
projectId as Id<"projects">
)
}
onProjectManagerClose={() => workspace.setProjectManagerOpen(false)}
onRepositoryChange={handleRepositoryChange}
onRepositoryConnect={(event) => {
event.preventDefault();
void projectWorkspace.projectWorkspace.connectRepository();
}}
onRestoreChecked={handleRestoreChecked}
onSettingsClose={() => workspace.setSettingsOpen(false)}
onSettingsOpen={() => workspace.setSettingsOpen(true)}
onSignOut={() => {
void handleSignOut();
}}
onWorkUnitChecked={handleWorkUnitChecked}
onWorkUnitSentBack={handleWorkUnitSentBack}
pendingAction={projectWorkspace.projectWorkspace.pendingAction}
projectManagerOpen={workspace.projectManagerOpen}
repositoryValue={projectWorkspace.projectWorkspace.repository}
settingsOpen={workspace.settingsOpen}
statusMessage={statusMessage}
userEmail={user?.email}
userInitials={getUserInitials(user?.name)}
userName={user?.name}
workUnits={workStack.visibleWorkUnits}
/>
);
}
if (screen === "work-chat") {
return (
<MobileWorkChatScreen
composerValue={workspace.composerValue}
data={projectWorkspace.data}
onBack={() => navigate(chatPath)}
onComposerChange={workspace.handleComposerChange}
onComposerMessageSubmit={workspace.handleComposerMessageSubmit}
onComposerSubmit={workspace.handleComposerSubmit}
statusMessage={statusMessage}
/>
);
}
if (screen === "home") { if (screen === "home") {
return <MobileHomeScreen {...screenProps} />; return <MobileHomeScreen {...screenProps} />;
} }

View File

@@ -0,0 +1,416 @@
import {
Conversation,
ConversationContent,
} from "@code/ui/components/ai-elements/conversation";
import { Button } from "@code/ui/components/button";
import { projectWorkNotices } from "@code/work-os";
import {
ChevronRight,
FolderGit2,
LoaderCircle,
Menu,
MessageSquareText,
ImagePlus,
Send,
Sparkles,
X,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
import { ChatMessage } from "@/components/chat/chat-message";
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
import { useChatImages } from "@/hooks/chat/use-chat-images";
import { useSliceOne } from "@/hooks/slice-one/use-slice-one";
import { useVisualViewportStyle } from "@/hooks/slice-one/use-visual-viewport";
import { chatImageToPromptImage } from "@/lib/chat/attachments";
import {
buildSliceOneTimeline,
findSourceMessageTarget,
} from "@/lib/slice-one/presentation";
type SliceWork = NonNullable<ReturnType<typeof useSliceOne>["works"]>[number];
const EMPTY_WORKS: readonly SliceWork[] = [];
interface WorkCardProps {
readonly onSourceSelect: (rawText: string) => void;
readonly work: SliceWork;
}
const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => {
const [sourcesOpen, setSourcesOpen] = useState(false);
const sources = work.signals.flatMap((signal) => signal.sources);
return (
<article className="border border-[#d7d3c7] bg-[#fffefa] p-4 text-[#20201d] shadow-[0_10px_30px_rgba(30,30,20,0.06)]">
<div className="flex items-start gap-3">
<span className="grid size-8 shrink-0 place-items-center bg-[#dcff68]">
<Sparkles className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
Proposed Work
</p>
<h2 className="mt-1 text-[15px] font-semibold leading-5">
{work.title}
</h2>
<p className="mt-1.5 text-[13px] leading-5 text-[#626057]">
{work.objective}
</p>
</div>
</div>
<button
className="mt-3 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs text-[#69675e]"
onClick={() => setSourcesOpen((open) => !open)}
type="button"
>
<span>
{sources.length} exact source{" "}
{sources.length === 1 ? "message" : "messages"}
</span>
<ChevronRight
className={`size-4 transition-transform ${sourcesOpen ? "rotate-90" : ""}`}
/>
</button>
{sourcesOpen ? (
<div className="mt-3 space-y-2">
{sources.map((source) => (
<button
className="flex w-full items-start gap-2 border-l-2 border-[#a8b750] bg-[#f4f2e9] px-3 py-2 text-left text-xs leading-5 hover:bg-[#ece9dd]"
key={source.messageId}
onClick={() => onSourceSelect(source.rawText)}
type="button"
>
<MessageSquareText className="mt-1 size-3.5 shrink-0 text-[#65713a]" />
<span className="min-w-0 flex-1">{source.rawText}</span>
</button>
))}
</div>
) : null}
</article>
);
};
export const SliceOnePage = () => {
const slice = useSliceOne();
const viewportStyle = useVisualViewportStyle();
const [draft, setDraft] = useState("");
const attachments = useChatImages();
const imageInput = useRef<HTMLInputElement>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const works = slice.works ?? EMPTY_WORKS;
const workById = useMemo(
() => new Map(works.map((work) => [String(work._id), work])),
[works]
);
const notices = useMemo(() => projectWorkNotices(works), [works]);
const timeline = useMemo(
() => buildSliceOneTimeline(slice.agent.messages, notices),
[notices, slice.agent.messages]
);
const busy =
slice.agent.status === "submitted" || slice.agent.status === "streaming";
const revealSourceMessage = (rawText: string) => {
const messageId = findSourceMessageTarget(slice.agent.messages, rawText);
if (!messageId) {
return;
}
setDrawerOpen(false);
setHighlightedMessageId(messageId);
if (highlightTimer.current) {
clearTimeout(highlightTimer.current);
}
requestAnimationFrame(() => {
document
.querySelector(`#${CSS.escape(`slice-message-${messageId}`)}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
});
highlightTimer.current = setTimeout(
() => setHighlightedMessageId(undefined),
1800
);
};
if (slice.projects === undefined) {
return (
<div className="grid min-h-svh place-items-center bg-[#f2f0e7]">
<LoaderCircle className="size-5 animate-spin" />
</div>
);
}
if (!slice.selectedProject) {
return (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] px-5 text-[#20201d]">
<form
className="w-full max-w-sm"
onSubmit={(event) => {
event.preventDefault();
void slice.connectRepository();
}}
>
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
<FolderGit2 className="size-5" />
</span>
<h1 className="mt-6 text-2xl font-semibold">Connect one project</h1>
<p className="mt-2 text-sm leading-6 text-[#68665e]">
Slice 1 turns actionable conversation into proposed Work with exact
provenance.
</p>
<input
aria-label="Public Git repository URL"
className="mt-6 h-12 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
onChange={(event) => slice.setRepository(event.target.value)}
placeholder="https://github.com/owner/repository"
required
value={slice.repository}
/>
{slice.error ? (
<p className="mt-2 text-xs text-red-700">{slice.error.message}</p>
) : null}
<Button
className="mt-3 h-12 w-full"
disabled={slice.pending}
type="submit"
>
{slice.pending ? (
<LoaderCircle className="size-4 animate-spin" />
) : null}
{slice.pending ? "Connecting" : "Connect project"}
</Button>
</form>
</main>
);
}
const send = async () => {
const message = draft.trim();
if (!message || busy) {
return;
}
const images = await Promise.all(
attachments.images.map(chatImageToPromptImage)
);
await slice.agent.sendMessage(message, { images });
setDraft("");
attachments.clear();
};
return (
<main
className="slice-one-surface fixed inset-x-0 top-0 flex min-h-0 overflow-hidden bg-[#f2f0e7] text-[#20201d]"
style={viewportStyle}
>
<section className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
<div className="min-w-0 flex-1 pr-2">
<select
aria-label="Current project"
className="block h-7 max-w-full border-0 bg-transparent pr-7 text-sm font-semibold text-[#20201d] outline-none"
onChange={(event) => slice.selectProject(event.target.value)}
value={slice.selectedProject.id}
>
{slice.projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
<p className="text-[10px] uppercase text-[#858277]">
Conversation to proposed Work
</p>
</div>
<button
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
onClick={() => setDrawerOpen(true)}
type="button"
>
<Menu className="size-4" /> Work{" "}
{slice.works === undefined ? "…" : works.length}
</button>
</header>
<Conversation className="min-h-0 flex-1">
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
{slice.agent.historyReady && timeline.length === 0 ? (
<div className="grid min-h-[55vh] place-items-center text-center">
<div>
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
<h1 className="mt-4 text-xl font-semibold">
What should move forward?
</h1>
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
Describe an outcome or problem. Casual conversation stays
conversation.
</p>
</div>
</div>
) : null}
{timeline.map((item) => {
if (item.kind === "work") {
const work = workById.get(item.notice.workId);
return work ? (
<div
className="chat-message ml-7"
key={`notice-${item.notice.eventId}`}
>
<p className="mb-2 text-[10px] font-semibold uppercase text-[#65713a]">
Work proposed from this conversation
</p>
<WorkCard
onSourceSelect={revealSourceMessage}
work={work}
/>
</div>
) : null;
}
return (
<div
className={`rounded-sm transition-colors duration-300 ${
highlightedMessageId === item.message.id
? "bg-[#dcff68]/70 ring-2 ring-[#7f9130] ring-offset-4 ring-offset-[#f2f0e7]"
: ""
}`}
id={`slice-message-${item.message.id}`}
key={item.message.id}
>
<ChatMessage hideToolActivity message={item.message} />
</div>
);
})}
{slice.agent.status === "submitted" ? (
<ChatThinkingResponse />
) : null}
</ConversationContent>
</Conversation>
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
{attachments.images.length > 0 ? (
<div className="mx-auto max-w-2xl">
<PendingChatAttachments
images={attachments.images}
onRemove={attachments.handleRemove}
/>
</div>
) : null}
{slice.agent.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{slice.agent.error.message}
</p>
) : null}
{attachments.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{attachments.error}
</p>
) : null}
<div className="mx-auto flex max-w-2xl items-end gap-2">
<input
accept="image/*"
aria-label="Attach images"
className="sr-only"
multiple
onChange={(event) => {
attachments.addFiles(event.target.files);
event.target.value = "";
}}
ref={imageInput}
type="file"
/>
<Button
aria-label="Attach images"
className="size-11 shrink-0"
disabled={busy}
onClick={() => imageInput.current?.click()}
size="icon"
type="button"
variant="outline"
>
<ImagePlus className="size-4" />
</Button>
<textarea
aria-label="Message Zopu"
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-sm outline-none focus:border-[#55564e]"
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void send();
}
}}
placeholder="Describe an outcome or problem…"
rows={1}
value={draft}
/>
<Button
aria-label="Send message"
className="size-11 shrink-0"
disabled={!draft.trim() || busy}
onClick={() => void send()}
size="icon"
type="button"
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
</section>
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-l border-[#d7d3c7] bg-[#e9e7de] p-4 lg:block">
<h2 className="text-sm font-semibold">Proposed Work</h2>
<p className="mb-4 text-xs text-[#747168]">
{works.length} durable outcomes
</p>
<div className="space-y-3">
{works.map((work) => (
<WorkCard
key={work._id}
onSourceSelect={revealSourceMessage}
work={work}
/>
))}
</div>
</aside>
{drawerOpen ? (
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden">
<button
aria-label="Close Work drawer"
className="absolute inset-0"
onClick={() => setDrawerOpen(false)}
type="button"
/>
<section className="absolute inset-y-0 right-0 flex w-[min(92vw,380px)] flex-col bg-[#e9e7de] shadow-2xl">
<header className="flex h-14 items-center border-b border-[#cfcbc0] px-4">
<h2 className="flex-1 text-sm font-semibold">Proposed Work</h2>
<button
aria-label="Close Work drawer"
className="grid size-9 place-items-center"
onClick={() => setDrawerOpen(false)}
type="button"
>
<X className="size-4" />
</button>
</header>
<div className="flex-1 space-y-3 overflow-y-auto p-4">
{works.length === 0 ? (
<p className="py-12 text-center text-sm text-[#747168]">
Actionable messages will appear here.
</p>
) : null}
{works.map((work) => (
<WorkCard
key={work._id}
onSourceSelect={revealSourceMessage}
work={work}
/>
))}
</div>
</section>
</div>
) : null}
</main>
);
};

View File

@@ -55,6 +55,25 @@ describe("useChatAgent", () => {
}); });
await chat.sendMessage("ready"); await chat.sendMessage("ready");
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("ready"); expect(mocks.agent.sendMessage).toHaveBeenCalledWith("ready", undefined);
});
test("forwards image attachments through to the Flue agent", async () => {
mocks.organization = { organizationId: "org-a" };
const images = [
{
data: "abc",
filename: "a.png",
mimeType: "image/png",
type: "image" as const,
},
];
const chat = useChatAgent();
await chat.sendMessage("describe this", { images });
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("describe this", {
images,
});
}); });
}); });

View File

@@ -1,3 +1,4 @@
import type { SendMessageOptions } from "@flue/react";
import { useFlueAgent } from "@flue/react"; import { useFlueAgent } from "@flue/react";
import { usePersonalOrganization } from "@/hooks/use-personal-organization"; import { usePersonalOrganization } from "@/hooks/use-personal-organization";
@@ -13,14 +14,17 @@ export const useOrganizationChatAgent = (
id: organization.organizationId, id: organization.organizationId,
}); });
const sendMessage = async (message: string): Promise<void> => { const sendMessage = async (
message: string,
options?: SendMessageOptions
): Promise<void> => {
if (!organization.organizationId) { if (!organization.organizationId) {
throw ( throw (
organization.error ?? organization.error ??
new Error("Personal organization is still being prepared") new Error("Personal organization is still being prepared")
); );
} }
await agent.sendMessage(message); await agent.sendMessage(message, options);
}; };
let { status } = agent; let { status } = agent;

View File

@@ -0,0 +1,76 @@
import { useEffect, useRef, useState } from "react";
import { MAX_CHAT_IMAGES, validateChatImage } from "@/lib/chat/attachments";
import type { PendingChatImage } from "@/lib/chat/attachments";
import { generateBrowserRequestId } from "@/lib/flue-transport";
export const useChatImages = () => {
const [images, setImages] = useState<PendingChatImage[]>([]);
const [error, setError] = useState<string>();
const previewUrls = useRef(new Set<string>());
useEffect(
() => () => {
for (const previewUrl of previewUrls.current) {
URL.revokeObjectURL(previewUrl);
}
},
[]
);
const addFiles = (files: FileList | null) => {
if (!files) {
return;
}
setError(undefined);
setImages((current) => {
const remaining = MAX_CHAT_IMAGES - current.length;
if (remaining <= 0) {
setError(`Attach up to ${MAX_CHAT_IMAGES} images`);
return current;
}
const next = [...current];
for (const file of [...files].slice(0, remaining)) {
const validation = validateChatImage(file);
if (!validation.accepted) {
setError(validation.message);
continue;
}
const previewUrl = URL.createObjectURL(file);
previewUrls.current.add(previewUrl);
next.push({
file,
id: generateBrowserRequestId(),
previewUrl,
});
}
if (files.length > remaining) {
setError(`Attach up to ${MAX_CHAT_IMAGES} images`);
}
return next;
});
};
const remove = (id: string) => {
setImages((current) => {
const removed = current.find((image) => image.id === id);
if (removed) {
URL.revokeObjectURL(removed.previewUrl);
previewUrls.current.delete(removed.previewUrl);
}
return current.filter((image) => image.id !== id);
});
setError(undefined);
};
const clear = () => {
for (const previewUrl of previewUrls.current) {
URL.revokeObjectURL(previewUrl);
}
previewUrls.current.clear();
setImages([]);
setError(undefined);
};
return { addFiles, clear, error, handleRemove: remove, images } as const;
};

View File

@@ -0,0 +1,72 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useQuery } from "convex/react";
import { useMemo, useState } from "react";
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
const toError = (error: unknown) =>
error instanceof Error ? error : new Error(String(error));
export const useSliceOne = () => {
const projects = useQuery(api.projects.list);
const importPublicGit = useAction(api.projects.importPublicGit);
const agent = useChatAgent();
const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null);
const [repository, setRepository] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<Error>();
const selectedProjectStillExists = projects?.some(
(project) => project.id === (selectedProjectId as unknown as string)
);
const activeProjectId = selectedProjectStillExists
? selectedProjectId
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
const works = useQuery(
api.works.listForProject,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const selectedProject = useMemo(
() =>
projects?.find(
(project) => project.id === (activeProjectId as unknown as string)
) ?? null,
[activeProjectId, projects]
);
const connectRepository = async () => {
const repositoryUrl = repository.trim();
if (!repositoryUrl || pending) {
return;
}
setPending(true);
setError(undefined);
try {
const project = await importPublicGit({ repositoryUrl });
setSelectedProjectId(project.id as unknown as Id<"projects">);
setRepository("");
} catch (caughtError) {
setError(toError(caughtError));
} finally {
setPending(false);
}
};
const selectProject = (projectId: string) => {
setSelectedProjectId(projectId as unknown as Id<"projects">);
};
return {
agent,
connectRepository,
error,
pending,
projects,
repository,
selectProject,
selectedProject,
setRepository,
works,
} as const;
};

View File

@@ -0,0 +1,26 @@
import { describe, expect, test } from "vitest";
import { visualViewportStyle } from "./use-visual-viewport";
describe("Slice 1 visual viewport", () => {
test("shrinks the application surface to the keyboard-visible height", () => {
expect(visualViewportStyle({ height: 500, offsetTop: 0 })).toEqual({
height: "500px",
transform: undefined,
});
});
test("tracks a panned visual viewport without scrolling the document", () => {
expect(visualViewportStyle({ height: 500, offsetTop: 44 })).toEqual({
height: "500px",
transform: "translateY(44px)",
});
});
test("uses a dynamic viewport fallback before browser measurement", () => {
expect(visualViewportStyle()).toEqual({
height: "100dvh",
transform: undefined,
});
});
});

View File

@@ -0,0 +1,52 @@
import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
export interface VisualViewportFrame {
readonly height: number;
readonly offsetTop: number;
}
const readVisualViewport = (): VisualViewportFrame => ({
height: Math.round(window.visualViewport?.height ?? window.innerHeight),
offsetTop: Math.round(window.visualViewport?.offsetTop ?? 0),
});
export const visualViewportStyle = (
frame?: VisualViewportFrame
): CSSProperties => ({
height: frame ? `${frame.height}px` : "100dvh",
transform: frame?.offsetTop ? `translateY(${frame.offsetTop}px)` : undefined,
});
export const useVisualViewportStyle = (): CSSProperties => {
const [frame, setFrame] = useState<VisualViewportFrame>();
useEffect(() => {
const root = document.documentElement;
const viewport = window.visualViewport;
let animationFrame = 0;
const update = () => {
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(() => {
setFrame(readVisualViewport());
});
};
root.classList.add("slice-one-viewport-lock");
update();
window.addEventListener("resize", update);
viewport?.addEventListener("resize", update);
viewport?.addEventListener("scroll", update);
return () => {
cancelAnimationFrame(animationFrame);
root.classList.remove("slice-one-viewport-lock");
window.removeEventListener("resize", update);
viewport?.removeEventListener("resize", update);
viewport?.removeEventListener("scroll", update);
};
}, []);
return visualViewportStyle(frame);
};

View File

@@ -13,24 +13,45 @@ import { STATUS_COPY } from "@/lib/chat/constants";
import { getMessageText } from "@/lib/chat/transforms"; import { getMessageText } from "@/lib/chat/transforms";
import { buildMobileWorkspaceView } from "@/lib/mobile-workspace/build-mobile-workspace-view"; import { buildMobileWorkspaceView } from "@/lib/mobile-workspace/build-mobile-workspace-view";
const WORK_UNIT_SCOPE_PATTERN =
/^<zopu-work-unit id="(?<workUnitId>[^"]+)">[\s\S]*?<message>(?<message>[\s\S]*?)<\/message>\s*<\/zopu-work-unit>$/u;
const toAssistantMessages = ( const toAssistantMessages = (
messages: ReturnType<typeof useOrganizationChatAgent>["messages"] messages: ReturnType<typeof useOrganizationChatAgent>["messages"],
): MobileAssistantMessageView[] => selectedWorkUnitId?: string
messages.flatMap((message) => { ): MobileAssistantMessageView[] => {
const result: MobileAssistantMessageView[] = [];
for (const message of messages) {
if (message.role !== "assistant" && message.role !== "user") { if (message.role !== "assistant" && message.role !== "user") {
return []; continue;
} }
const text = getMessageText(message); const rawText = getMessageText(message);
return text if (!rawText) {
? [ continue;
{ }
if (!selectedWorkUnitId) {
result.push({ id: message.id, role: message.role, text: rawText });
continue;
}
if (message.role === "user") {
const match = WORK_UNIT_SCOPE_PATTERN.exec(rawText);
if (match?.groups?.workUnitId !== selectedWorkUnitId) {
result.length = 0;
continue;
}
result.push({
id: message.id, id: message.id,
role: message.role, role: message.role,
text, text: match.groups.message?.trim() ?? rawText,
},
]
: [];
}); });
continue;
}
if (result.length > 0) {
result.push({ id: message.id, role: message.role, text: rawText });
}
}
return result;
};
const getStatusTone = ( const getStatusTone = (
status: ReturnType<typeof useOrganizationChatAgent>["status"] status: ReturnType<typeof useOrganizationChatAgent>["status"]
@@ -57,12 +78,23 @@ export const useMobileProjectWorkspace = ({
? { organizationId: organization.organizationId } ? { organizationId: organization.organizationId }
: "skip" : "skip"
); );
const selectedIssue = projectWorkspace.issues?.find(
(issue) => String(issue._id) === selectedWorkUnitId
);
const sendMessage = (message: string) => {
if (!selectedIssue) {
return chatAgent.sendMessage(message);
}
return chatAgent.sendMessage(
`<zopu-work-unit id="${selectedIssue._id}">\n<context>Issue #${selectedIssue.number}: ${selectedIssue.title}</context>\n<message>${message}</message>\n</zopu-work-unit>`
);
};
const assistant: MobileAssistantView = { const assistant: MobileAssistantView = {
isBusy: isBusy:
chatAgent.status === "connecting" || chatAgent.status === "connecting" ||
chatAgent.status === "streaming" || chatAgent.status === "streaming" ||
chatAgent.status === "submitted", chatAgent.status === "submitted",
messages: toAssistantMessages(chatAgent.messages), messages: toAssistantMessages(chatAgent.messages, selectedWorkUnitId),
statusLabel: STATUS_COPY[chatAgent.status], statusLabel: STATUS_COPY[chatAgent.status],
statusTone: getStatusTone(chatAgent.status), statusTone: getStatusTone(chatAgent.status),
}; };
@@ -79,13 +111,16 @@ export const useMobileProjectWorkspace = ({
selectedWorkUnitId, selectedWorkUnitId,
signals, signals,
}), }),
error: organization.error ?? chatAgent.error, error:
organization.error ??
chatAgent.error ??
(projectWorkspace.error ? new Error(projectWorkspace.error) : undefined),
organization, organization,
projectWorkspace, projectWorkspace,
raiseIssue: projectWorkspace.raiseIssue, raiseIssue: projectWorkspace.raiseIssue,
raiseIssueFromSignal: (signalId: string) => raiseIssueFromSignal: (signalId: string) =>
projectWorkspace.raiseIssueFromSignal(signalId as Id<"signals">), projectWorkspace.raiseIssueFromSignal(signalId as Id<"signals">),
sendMessage: chatAgent.sendMessage, sendMessage,
startWorkUnit: projectWorkspace.startWorkUnit, startWorkUnit: projectWorkspace.startWorkUnit,
} as const; } as const;
}; };

View File

@@ -0,0 +1,66 @@
import type { MobileWorkUnitView } from "@code/ui/components/mobile-product";
import { useCallback, useMemo, useState } from "react";
export const useMobileWorkStack = (
workUnits: readonly MobileWorkUnitView[]
) => {
const [order, setOrder] = useState<readonly string[]>([]);
const [checkedIds, setCheckedIds] = useState<ReadonlySet<string>>(
() => new Set()
);
const workUnitsById = useMemo(
() => new Map(workUnits.map((workUnit) => [workUnit.id, workUnit])),
[workUnits]
);
const availableIds = useMemo(
() => new Set(workUnitsById.keys()),
[workUnitsById]
);
const visibleWorkUnits = useMemo(() => {
const retainedOrder = order.filter(
(workUnitId) =>
availableIds.has(workUnitId) && !checkedIds.has(workUnitId)
);
const retainedIds = new Set(retainedOrder);
const addedIds = workUnits
.map((workUnit) => workUnit.id)
.filter(
(workUnitId) =>
!retainedIds.has(workUnitId) && !checkedIds.has(workUnitId)
);
return [...retainedOrder, ...addedIds].flatMap((workUnitId) => {
const workUnit = workUnitsById.get(workUnitId);
return workUnit ? [workUnit] : [];
});
}, [availableIds, checkedIds, order, workUnits, workUnitsById]);
const sendToBack = useCallback(
(workUnitId: string) => {
if (visibleWorkUnits[0]?.id !== workUnitId) {
return;
}
setOrder([...visibleWorkUnits.slice(1).map(({ id }) => id), workUnitId]);
},
[visibleWorkUnits]
);
const markChecked = useCallback((workUnitId: string) => {
setCheckedIds((current) => new Set(current).add(workUnitId));
}, []);
const restoreChecked = useCallback(() => {
setCheckedIds(new Set());
setOrder([]);
}, []);
return {
checkedCount: [...checkedIds].filter((workUnitId) =>
availableIds.has(workUnitId)
).length,
markChecked,
restoreChecked,
sendToBack,
visibleWorkUnits,
} as const;
};

View File

@@ -19,6 +19,8 @@ export const useMobileWorkspace = ({
const [issueBody, setIssueBody] = useState(""); const [issueBody, setIssueBody] = useState("");
const [issueTitle, setIssueTitle] = useState(""); const [issueTitle, setIssueTitle] = useState("");
const [expanded, setExpanded] = useState(initialExpanded); const [expanded, setExpanded] = useState(initialExpanded);
const [projectManagerOpen, setProjectManagerOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [statusMessage, setStatusMessage] = useState<string>(); const [statusMessage, setStatusMessage] = useState<string>();
const handleComposerChange = (value: string) => { const handleComposerChange = (value: string) => {
@@ -26,23 +28,32 @@ export const useMobileWorkspace = ({
setStatusMessage(undefined); setStatusMessage(undefined);
}; };
const handleComposerSubmit = (event: FormEvent<HTMLFormElement>) => { const submitMessage = async (message: string) => {
event.preventDefault(); const trimmedMessage = message.trim();
const message = composerValue.trim(); if (!trimmedMessage) {
if (!message) {
return; return;
} }
setStatusMessage("Sending to Zopu…"); setStatusMessage("Sending to Zopu…");
const sendMessage = async () => {
try { try {
await onSend(message); await onSend(trimmedMessage);
setComposerValue(""); setComposerValue("");
setStatusMessage("Sent to Zopu"); setStatusMessage("Sent to Zopu");
} catch (error) { } catch (error) {
setStatusMessage(errorMessage(error)); setStatusMessage(errorMessage(error));
throw error;
} }
}; };
void sendMessage();
const handleComposerSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const sendCurrentMessage = async () => {
try {
await submitMessage(composerValue);
} catch {
// submitMessage already exposes the failure through statusMessage.
}
};
void sendCurrentMessage();
}; };
const handleCreateIssueSubmit = (event: FormEvent<HTMLFormElement>) => { const handleCreateIssueSubmit = (event: FormEvent<HTMLFormElement>) => {
@@ -70,13 +81,18 @@ export const useMobileWorkspace = ({
composerValue, composerValue,
expanded, expanded,
handleComposerChange, handleComposerChange,
handleComposerMessageSubmit: submitMessage,
handleComposerSubmit, handleComposerSubmit,
handleCreateIssueSubmit, handleCreateIssueSubmit,
issueBody, issueBody,
issueTitle, issueTitle,
projectManagerOpen,
setExpanded, setExpanded,
setIssueBody, setIssueBody,
setIssueTitle, setIssueTitle,
setProjectManagerOpen,
setSettingsOpen,
settingsOpen,
statusMessage, statusMessage,
}; };
}; };

View File

@@ -6,6 +6,13 @@ body {
min-height: 100%; min-height: 100%;
} }
html.slice-one-viewport-lock,
html.slice-one-viewport-lock body {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
}
.ai-conversation-mobile > div { .ai-conversation-mobile > div {
scrollbar-gutter: auto !important; scrollbar-gutter: auto !important;
} }
@@ -96,6 +103,12 @@ body {
color: var(--foreground); color: var(--foreground);
} }
.slice-one-surface .chat-markdown,
.slice-one-surface .chat-reasoning,
.slice-one-surface .thinking-line {
color: #232321;
}
.chat-markdown > :first-child { .chat-markdown > :first-child {
margin-top: 0; margin-top: 0;
} }

View File

@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import {
MAX_CHAT_IMAGE_BYTES,
bytesToBase64,
validateChatImage,
} from "./attachments";
describe("chat image attachments", () => {
test("accepts supported image payloads within the upload limit", () => {
expect(validateChatImage({ size: 1024, type: "image/png" })).toEqual({
accepted: true,
});
});
test("rejects non-images and oversized images before transport", () => {
expect(validateChatImage({ size: 1024, type: "text/plain" }).accepted).toBe(
false
);
expect(
validateChatImage({
size: MAX_CHAT_IMAGE_BYTES + 1,
type: "image/jpeg",
}).accepted
).toBe(false);
});
test("encodes image bytes for the Flue prompt contract", () => {
expect(bytesToBase64(new Uint8Array([90, 111, 112, 117]))).toBe("Wm9wdQ==");
});
});

View File

@@ -0,0 +1,47 @@
import type { AgentPromptImage } from "@flue/react";
export const MAX_CHAT_IMAGES = 4;
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
export interface PendingChatImage {
readonly file: File;
readonly id: string;
readonly previewUrl: string;
}
export interface ImageValidationResult {
readonly accepted: boolean;
readonly message?: string;
}
export const validateChatImage = (
file: Pick<File, "size" | "type">
): ImageValidationResult => {
if (!file.type.startsWith("image/")) {
return { accepted: false, message: "Only image files can be attached" };
}
if (file.size > MAX_CHAT_IMAGE_BYTES) {
return {
accepted: false,
message: "Images must be 10 MB or smaller",
};
}
return { accepted: true };
};
export const bytesToBase64 = (bytes: Uint8Array): string => {
let binary = "";
for (const byte of bytes) {
binary += String.fromCodePoint(byte);
}
return btoa(binary);
};
export const chatImageToPromptImage = async (
image: PendingChatImage
): Promise<AgentPromptImage> => ({
data: bytesToBase64(new Uint8Array(await image.file.arrayBuffer())),
filename: image.file.name,
mimeType: image.file.type,
type: "image",
});

View File

@@ -10,7 +10,8 @@ export const CHAT_AGENT = {
name: "zopu", name: "zopu",
} as const; } as const;
export const MODEL_LABEL = "GLM-5.2"; export const MODEL_ID = "openrouter/openai/gpt-oss-20b:free";
export const MODEL_LABEL = "GPT-OSS 20B Free";
export const SUGGESTIONS = [ export const SUGGESTIONS = [
["Think it through", "Help me make a difficult decision"], ["Think it through", "Help me make a difficult decision"],

View File

@@ -0,0 +1,78 @@
import type { FlueConversationMessage } from "@flue/react";
import { describe, expect, test } from "vitest";
import {
extractThinkingMarkup,
getReasoningText,
hasToolActivity,
isReasoningStreaming,
} from "./transforms";
const message = (
parts: FlueConversationMessage["parts"]
): FlueConversationMessage => ({
id: "message-1",
parts,
role: "assistant",
});
describe("hasToolActivity", () => {
test("detects an orchestration turn", () => {
expect(
hasToolActivity(
message([
{
input: { projectId: "project-1" },
state: "input-available",
toolCallId: "tool-1",
toolName: "list_proposed_work",
type: "dynamic-tool",
},
])
)
).toBe(true);
});
test("keeps a product-facing assistant response", () => {
expect(
hasToolActivity(
message([
{
state: "done",
text: "Captured the signal and proposed Work.",
type: "text",
},
])
)
).toBe(false);
});
});
describe("reasoning traces", () => {
test("extracts completed and streaming MiniMax think blocks", () => {
expect(
extractThinkingMarkup(
"<think>First thought</think><think>Current thought"
)
).toBe("First thought\n\nCurrent thought");
});
test("combines native reasoning with inline thinking", () => {
expect(
getReasoningText(
message([
{ state: "streaming", text: "Native thought", type: "reasoning" },
{ state: "streaming", text: "<think>Inline thought", type: "text" },
])
)
).toBe("Native thought\n\nInline thought");
});
test("recognizes an open streamed think block as live reasoning", () => {
expect(
isReasoningStreaming(
message([{ state: "streaming", text: "<think>Working", type: "text" }])
)
).toBe(true);
});
});

View File

@@ -15,13 +15,59 @@ export const stripThinkingMarkup = (text: string): string => {
return visibleText.replaceAll("</think>", "").trimStart(); return visibleText.replaceAll("</think>", "").trimStart();
}; };
export const getMessageText = (message: FlueConversationMessage): string => export const extractThinkingMarkup = (text: string): string => {
stripThinkingMarkup( const completed = [
...text.matchAll(/<think>(?<content>[\s\S]*?)<\/think>/giu),
]
.map((match) => match.groups?.content?.trim())
.filter((value): value is string => Boolean(value));
const lowerText = text.toLowerCase();
const openTagIndex = lowerText.lastIndexOf("<think>");
const closeTagIndex = lowerText.lastIndexOf("</think>");
if (openTagIndex > closeTagIndex) {
const active = text.slice(openTagIndex + "<think>".length).trim();
if (active) {
completed.push(active);
}
}
return completed.join("\n\n");
};
export const getRawMessageText = (message: FlueConversationMessage): string =>
message.parts message.parts
.filter((part) => part.type === "text") .filter((part) => part.type === "text")
.map((part) => part.text) .map((part) => part.text)
.join("\n") .join("\n");
export const getMessageText = (message: FlueConversationMessage): string =>
stripThinkingMarkup(getRawMessageText(message));
export const getReasoningText = (message: FlueConversationMessage): string => {
const nativeReasoning = message.parts
.filter((part) => part.type === "reasoning")
.map((part) => part.text)
.join("\n");
const inlineReasoning = extractThinkingMarkup(getRawMessageText(message));
return [nativeReasoning, inlineReasoning].filter(Boolean).join("\n\n");
};
export const isReasoningStreaming = (
message: FlueConversationMessage
): boolean => {
const nativeReasoningStreaming = message.parts.some(
(part) => part.type === "reasoning" && part.state === "streaming"
); );
const rawText = getRawMessageText(message).toLowerCase();
const inlineReasoningStreaming =
rawText.lastIndexOf("<think>") > rawText.lastIndexOf("</think>") &&
message.parts.some(
(part) => part.type === "text" && part.state === "streaming"
);
return nativeReasoningStreaming || inlineReasoningStreaming;
};
export const hasToolActivity = (message: FlueConversationMessage): boolean =>
message.parts.some((part) => part.type === "dynamic-tool");
export const isMessageStreaming = (message: FlueConversationMessage): boolean => export const isMessageStreaming = (message: FlueConversationMessage): boolean =>
message.parts.some((part) => { message.parts.some((part) => {

View File

@@ -1,4 +1,5 @@
import type { import type {
AgentPromptImage,
AgentStatus, AgentStatus,
FlueConversationMessage, FlueConversationMessage,
FlueConversationPart, FlueConversationPart,
@@ -8,7 +9,10 @@ export interface ChatAgentState {
error?: Error; error?: Error;
historyReady: boolean; historyReady: boolean;
messages: FlueConversationMessage[]; messages: FlueConversationMessage[];
sendMessage: (message: string) => Promise<void>; sendMessage: (
message: string,
options?: { readonly images?: AgentPromptImage[] }
) => Promise<void>;
status: AgentStatus; status: AgentStatus;
} }
@@ -30,6 +34,7 @@ export interface ChatHeaderProps {
} }
export interface ChatMessageProps { export interface ChatMessageProps {
hideToolActivity?: boolean;
message: FlueConversationMessage; message: FlueConversationMessage;
} }

View File

@@ -1,9 +1,15 @@
import { createFlueClient } from "@flue/sdk"; import { createFlueClient } from "@flue/sdk";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { createFlueFetch } from "./flue-transport"; import { createFlueFetch, generateBrowserRequestId } from "./flue-transport";
const BASE_URL = new URL("https://flue.example/api/"); const BASE_URL = new URL("https://flue.example/api/");
const GLOBAL_RECEIVER_FETCH = function globalReceiverFetch(this: unknown) {
if (this !== globalThis) {
throw new TypeError("fetch receiver must be globalThis");
}
return Promise.resolve(new Response(null, { status: 204 }));
} as typeof fetch;
describe("createFlueFetch", () => { describe("createFlueFetch", () => {
test("overlapping SDK agent sends keep distinct request IDs", async () => { test("overlapping SDK agent sends keep distinct request IDs", async () => {
@@ -87,6 +93,37 @@ describe("createFlueFetch", () => {
]); ]);
}); });
test("invokes fetch with the browser global receiver", async () => {
await expect(
createFlueFetch({ baseUrl: BASE_URL, fetchImpl: GLOBAL_RECEIVER_FETCH })(
"https://flue.example/api/agents/zopu/org-a?view=history"
)
).resolves.toBeInstanceOf(Response);
});
test("uses the default browser request ID generator without losing its receiver", async () => {
const capturedHeaders: Headers[] = [];
const fetchImpl: typeof fetch = (_input, init) => {
capturedHeaders.push(new Headers(init?.headers));
return Promise.resolve(new Response(null, { status: 204 }));
};
await createFlueFetch({ baseUrl: BASE_URL, fetchImpl })(
"https://flue.example/api/agents/zopu/org-a",
{ method: "POST" }
);
expect(capturedHeaders[0]?.get("x-zopu-request-id")).toMatch(
/^[0-9a-f-]{36}$/u
);
});
test("generates a UUID without requiring crypto.randomUUID", () => {
expect(generateBrowserRequestId()).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
);
});
test("history and stream observation requests remain untagged", async () => { test("history and stream observation requests remain untagged", async () => {
const capturedHeaders: Headers[] = []; const capturedHeaders: Headers[] = [];
const fetchImpl: typeof fetch = (_input, init) => { const fetchImpl: typeof fetch = (_input, init) => {

View File

@@ -4,6 +4,46 @@ interface FlueFetchOptions {
readonly generateRequestId?: () => string; readonly generateRequestId?: () => string;
} }
export const generateBrowserRequestId = (): string => {
const bytes = new Uint8Array(16);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = ((bytes[6] ?? 0) % 16) + 64;
bytes[8] = ((bytes[8] ?? 0) % 64) + 128;
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
};
const addRequestContext = async (
response: Response,
method: string,
requestUrl: URL
): Promise<Response> => {
if (response.ok) {
return response;
}
const responseClone = response.clone();
const responseText = await responseClone.text();
const detail = responseText.trim() || "request failed";
return Response.json(
{
error: {
message: `${method} ${requestUrl.pathname}${requestUrl.search}: ${detail}`,
},
},
{
headers: response.headers,
status: response.status,
statusText: response.statusText,
}
);
};
/** /**
* Build the Flue transport used by the browser client. * Build the Flue transport used by the browser client.
* *
@@ -15,7 +55,7 @@ interface FlueFetchOptions {
export const createFlueFetch = ({ export const createFlueFetch = ({
baseUrl, baseUrl,
fetchImpl = fetch, fetchImpl = fetch,
generateRequestId = crypto.randomUUID, generateRequestId = generateBrowserRequestId,
}: FlueFetchOptions): typeof fetch => { }: FlueFetchOptions): typeof fetch => {
const basePath = baseUrl.pathname.replace(/\/+$/u, ""); const basePath = baseUrl.pathname.replace(/\/+$/u, "");
@@ -47,11 +87,9 @@ export const createFlueFetch = ({
requestInit = { ...init, headers }; requestInit = { ...init, headers };
} }
const response = await fetchImpl(input, requestInit); const rawResponse = await fetchImpl.call(globalThis, input, requestInit);
if ( const response = await addRequestContext(rawResponse, method, requestUrl);
!response.ok || if (!response.headers.get("content-type")?.includes("application/json")) {
!response.headers.get("content-type")?.includes("application/json")
) {
return response; return response;
} }

View File

@@ -125,11 +125,16 @@ const relevantSignalsForProject = (
const makeProjectViews = ( const makeProjectViews = (
projects: readonly ProjectView[] | undefined projects: readonly ProjectView[] | undefined
): readonly MobileProjectView[] => ): readonly MobileProjectView[] =>
projects?.map((project) => ({ projects?.map((project) => {
connected: project.sources.length > 0, const [source] = project.sources;
return {
connected: Boolean(source),
host: source?.host,
id: project.id, id: project.id,
name: project.name, name: project.name,
})) ?? []; repositoryPath: source?.repositoryPath,
};
}) ?? [];
export const buildMobileWorkspaceView = ({ export const buildMobileWorkspaceView = ({
artifacts, artifacts,

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { getMobileStatusMessage } from "./mobile-status-message";
describe("getMobileStatusMessage", () => {
it("keeps local composer feedback", () => {
expect(
getMobileStatusMessage({
assistantError: "Flue API error 401",
localStatus: "Sent to Zopu",
})
).toBe("Sent to Zopu");
});
it("does not expose Flue authorization internals", () => {
expect(
getMobileStatusMessage({
assistantError:
'Flue API error 401: GET /agents/zopu/example: {"error":"Unauthorized"}',
})
).toBe("Zopu is reconnecting…");
});
});

View File

@@ -0,0 +1,25 @@
interface MobileStatusMessageInput {
readonly assistantError?: string;
readonly localStatus?: string;
}
const isAgentAuthorizationError = (message: string) =>
message.includes("Flue API error 401") ||
message.includes('"Unauthorized"') ||
message.includes("GET /agents/");
export const getMobileStatusMessage = ({
assistantError,
localStatus,
}: MobileStatusMessageInput): string | undefined => {
if (localStatus) {
return localStatus;
}
if (!assistantError) {
return undefined;
}
if (isAgentAuthorizationError(assistantError)) {
return "Zopu is reconnecting…";
}
return "Zopu needs attention";
};

View File

@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "vitest";
const source = (relativePath: string) =>
readFileSync(new URL(relativePath, import.meta.url), "utf-8");
describe("Slice 1 frontend regression contracts", () => {
test("keeps keyboard resizing on the visual viewport instead of page scroll", () => {
const page = source("../../components/slice-one/slice-one-page.tsx");
const root = source("../../root.tsx");
const styles = source("../../index.css");
expect(root).toContain("interactive-widget=resizes-content");
expect(page).toContain("style={viewportStyle}");
expect(page).toContain("fixed inset-x-0 top-0");
expect(page).not.toContain("slice-one-surface flex h-svh");
expect(styles).toContain("html.slice-one-viewport-lock body");
expect(styles).toContain("overflow: hidden");
});
test("keeps the responsive shell shrinkable with a pinned composer", () => {
const page = source("../../components/slice-one/slice-one-page.tsx");
expect(page).toContain('className="flex min-w-0 flex-1 flex-col"');
expect(page).toContain('<Conversation className="min-h-0 flex-1">');
expect(page).toContain('className="shrink-0 border-t');
expect(page).toContain(
'className="mx-auto flex max-w-2xl items-end gap-2"'
);
});
test("keeps attachment strips contained and markdown capabilities enabled", () => {
const attachment = source("../../components/chat/chat-attachments.tsx");
const attachmentPrimitive = source(
"../../../../../packages/ui/src/components/attachment.tsx"
);
const chatMessage = source("../../components/chat/chat-message.tsx");
const messageRenderer = source(
"../../../../../packages/ui/src/components/ai-elements/message.tsx"
);
expect(attachment).toContain('className="max-w-full');
expect(attachment).toContain("useAuthenticatedImageSource");
expect(attachment).toContain(`authorization: \`Bearer \${accessToken}\``);
expect(attachmentPrimitive).toContain("overflow-x-auto");
expect(chatMessage).toContain("<MessageAttachments parts={fileParts} />");
expect(chatMessage).toContain("<MessageResponse");
expect(messageRenderer).toContain("const streamdownPlugins = {");
expect(messageRenderer).toContain("mermaid");
expect(messageRenderer).toContain("plugins={streamdownPlugins}");
});
});

View File

@@ -0,0 +1,62 @@
import type { WorkNotice } from "@code/work-os";
import type { FlueConversationMessage } from "@flue/react";
import { describe, expect, test } from "vitest";
import { buildSliceOneTimeline, findSourceMessageTarget } from "./presentation";
const textMessage = (
id: string,
role: "assistant" | "user",
text: string
): FlueConversationMessage => ({
id,
parts: [{ state: "done", text, type: "text" }],
role,
});
const notice: WorkNotice = {
createdAt: 10,
eventId: "event-1",
sourceMessageIds: ["source-1"],
sourceTexts: ["Build the phone flow."],
workId: "work-1",
};
describe("Slice 1 presentation", () => {
test("places proposed Work after the assistant response to its source", () => {
const timeline = buildSliceOneTimeline(
[
textMessage("user-1", "user", "Build the phone flow."),
{
id: "tool-1",
parts: [
{
input: {},
state: "input-available",
toolCallId: "call-1",
toolName: "create_signal",
type: "dynamic-tool",
},
],
role: "assistant",
},
textMessage("assistant-1", "assistant", "Captured and proposed Work."),
],
[notice]
);
expect(
timeline.map((item) =>
item.kind === "message" ? item.message.id : item.notice.workId
)
).toEqual(["user-1", "assistant-1", "work-1"]);
});
test("finds the exact source message without trimming it", () => {
const messages = [textMessage("user-1", "user", " Exact source ")];
expect(findSourceMessageTarget(messages, " Exact source ")).toBe(
"user-1"
);
expect(findSourceMessageTarget(messages, "Exact source")).toBeUndefined();
});
});

View File

@@ -0,0 +1,84 @@
import type { WorkNotice } from "@code/work-os";
import type { FlueConversationMessage } from "@flue/react";
import {
getMessageText,
getRawMessageText,
getReasoningText,
hasToolActivity,
} from "@/lib/chat/transforms";
export type SliceTimelineItem =
| {
readonly kind: "message";
readonly message: FlueConversationMessage;
}
| { readonly kind: "work"; readonly notice: WorkNotice };
export const isSliceOneVisibleMessage = (
message: FlueConversationMessage
): boolean =>
message.role === "user" ||
!hasToolActivity(message) ||
getMessageText(message).length > 0 ||
getReasoningText(message).length > 0;
const targetIndexForNotice = (
messages: readonly FlueConversationMessage[],
notice: WorkNotice
): number => {
const sourceIndexes = notice.sourceTexts.flatMap((sourceText) => {
const index = messages.findIndex(
(message) =>
message.role === "user" && getRawMessageText(message) === sourceText
);
return index === -1 ? [] : [index];
});
if (sourceIndexes.length === 0) {
return messages.length - 1;
}
const sourceIndex = Math.max(...sourceIndexes);
const responseOffset = messages
.slice(sourceIndex + 1)
.findIndex(
(message) => message.role === "assistant" && !hasToolActivity(message)
);
return responseOffset === -1 ? sourceIndex : sourceIndex + responseOffset + 1;
};
export const buildSliceOneTimeline = (
allMessages: readonly FlueConversationMessage[],
notices: readonly WorkNotice[]
): readonly SliceTimelineItem[] => {
const messages = allMessages.filter(isSliceOneVisibleMessage);
const noticesByMessageIndex = new Map<number, WorkNotice[]>();
for (const notice of notices) {
const targetIndex = targetIndexForNotice(messages, notice);
const atTarget = noticesByMessageIndex.get(targetIndex) ?? [];
atTarget.push(notice);
noticesByMessageIndex.set(targetIndex, atTarget);
}
const timeline: SliceTimelineItem[] = [];
for (const [index, message] of messages.entries()) {
timeline.push({ kind: "message", message });
for (const notice of noticesByMessageIndex.get(index) ?? []) {
timeline.push({ kind: "work", notice });
}
}
if (messages.length === 0) {
for (const notice of noticesByMessageIndex.get(-1) ?? []) {
timeline.push({ kind: "work", notice });
}
}
return timeline;
};
export const findSourceMessageTarget = (
messages: readonly FlueConversationMessage[],
sourceText: string
): string | undefined =>
messages.find(
(message) =>
message.role === "user" && getRawMessageText(message) === sourceText
)?.id;

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { getUserInitials } from "./get-user-initials";
describe("getUserInitials", () => {
it("uses the first two name parts", () => {
expect(getUserInitials("Sai Karthik")).toBe("SK");
});
it("uses a neutral fallback while auth loads", () => {
expect(getUserInitials()).toBe("U");
});
});

View File

@@ -0,0 +1,10 @@
export const getUserInitials = (name?: string): string => {
const parts = name?.trim().split(/\s+/u).filter(Boolean) ?? [];
if (parts.length === 0) {
return "U";
}
return parts
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
};

View File

@@ -64,7 +64,10 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
<html lang="en"> <html lang="en">
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
/>
<Meta /> <Meta />
<Links /> <Links />
</head> </head>

View File

@@ -11,7 +11,8 @@ export default [
layout("./routes/app/layout.tsx", [ layout("./routes/app/layout.tsx", [
layout("./routes/app/mobile/layout.tsx", [ layout("./routes/app/mobile/layout.tsx", [
index("./routes/app/mobile/page.tsx"), index("./routes/app/mobile/page.tsx"),
route("mobile/chat", "./routes/app/mobile/chat/page.tsx"), route("chat", "./routes/app/mobile/chat/page.tsx"),
route("chat/:workUnitId", "./routes/app/mobile/chat/work/page.tsx"),
route("work", "./routes/app/mobile/work-list/page.tsx"), route("work", "./routes/app/mobile/work-list/page.tsx"),
route("work/:workUnitId", "./routes/app/mobile/work/page.tsx"), route("work/:workUnitId", "./routes/app/mobile/work/page.tsx"),
]), ]),

View File

@@ -1,5 +1,5 @@
import { WorkOsPage } from "@/components/work-os/work-os-page"; import { SliceOnePage } from "@/components/slice-one/slice-one-page";
export default function Dashboard() { export default function Dashboard() {
return <WorkOsPage />; return <SliceOnePage />;
} }

View File

@@ -1,4 +1,4 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page"; import { SliceOnePage } from "@/components/slice-one/slice-one-page";
import type { Route } from "./+types/page"; import type { Route } from "./+types/page";
@@ -8,5 +8,5 @@ export const meta = (_args: Route.MetaArgs) => [
]; ];
export default function MobileChatPage() { export default function MobileChatPage() {
return <MobileFlowPage screen="assistant-chat" />; return <SliceOnePage />;
} }

View File

@@ -0,0 +1,15 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{ title: "Work chat | Zopu" },
{
content: "Chat with Zopu in the context of a selected work unit",
name: "description",
},
];
export default function MobileWorkChatPage() {
return <MobileFlowPage screen="work-chat" />;
}

View File

@@ -1,15 +1,5 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page"; import { SliceOnePage } from "@/components/slice-one/slice-one-page";
import type { Route } from "./+types/page"; export default function MobileLandingRedirect() {
return <SliceOnePage />;
export const meta = (_args: Route.MetaArgs) => [
{ title: "Daily focus | Zopu" },
{
content: "Supervise active work and open the next action",
name: "description",
},
];
export default function MobileHomePage() {
return <MobileFlowPage screen="home" />;
} }

View File

@@ -6,6 +6,9 @@ import { defineConfig } from "vite-plus";
export default defineConfig({ export default defineConfig({
envDir: path.resolve(import.meta.dirname, "../.."), envDir: path.resolve(import.meta.dirname, "../.."),
server: {
allowedHosts: true,
},
plugins: [tailwindcss(), reactRouter()], plugins: [tailwindcss(), reactRouter()],
resolve: { resolve: {
dedupe: ["convex", "react", "react-dom"], dedupe: ["convex", "react", "react-dom"],

604
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -8,11 +8,20 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 1. Convex (control plane) — REQUIRED # 1. Convex (control plane) — REQUIRED
# Convex is already deployed; provide the production deployment URL. # Public URLs for the self-hosted Convex deployment.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CONVEX_URL=https://your-deployment.convex.cloud CONVEX_URL=https://your-deployment.convex.cloud
CONVEX_SITE_URL=https://your-deployment.convex.site CONVEX_SITE_URL=https://your-deployment.convex.site
SITE_URL=http://localhost:5173 SITE_URL=http://localhost:5173
VITE_CONVEX_URL=https://your-deployment.convex.cloud
VITE_CONVEX_SITE_URL=https://your-deployment.convex.site
VITE_FLUE_URL=http://localhost:3583
# Self-hosted Convex origins used by convex/docker-compose.yml
CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud
CONVEX_SITE_ORIGIN=https://your-deployment.convex.site
CONVEX_INSTANCE_NAME=zopu-production
CONVEX_INSTANCE_SECRET=
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 2. Self-hosted Git / Gitea — REQUIRED for issue lifecycle # 2. Self-hosted Git / Gitea — REQUIRED for issue lifecycle
@@ -46,16 +55,15 @@ AGENT_MODEL_MAX_TOKENS=131072
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5. Zopu agent service (Flue) # 5. Zopu agent service (Flue)
# FLUE_DB_TOKEN authenticates the Flue persistence adapter. # FLUE_DB_TOKEN authenticates the Flue persistence adapter.
# PORT is the Flue Node HTTP server listen port. # zopu-agent.service pins the Flue Node server to port 3583.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FLUE_DB_TOKEN=replace-with-long-random-token FLUE_DB_TOKEN=replace-with-long-random-token
PORT=3583
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 6. Daemon identity # 6. Daemon identity
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DAEMON_ID=zopu-dedicated DAEMON_ID=zopu-dedicated
DAEMON_NAME=Zopu Dedicated Server DAEMON_NAME=Zopu-Dedicated-Server
DAEMON_VERSION=0.0.0 DAEMON_VERSION=0.0.0
DAEMON_HEARTBEAT_MS=15000 DAEMON_HEARTBEAT_MS=15000
DAEMON_COMMAND_LEASE_MS=60000 DAEMON_COMMAND_LEASE_MS=60000

View File

@@ -1,9 +1,6 @@
# Zopu Single-Node Runtime Deployment # Zopu Single-Node Runtime Deployment
Deployment artifacts for the Zopu execution plane on a single Debian dedicated Deployment artifacts for the complete Zopu stack on a single Debian dedicated server: the React Router web app, a persistent self-hosted Convex control plane, the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent service, Docker Engine, and supporting infrastructure.
server. Convex is already deployed as the control plane; this lane deploys only
the execution plane: the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent
service, Docker Engine for future Orb sandboxes, and supporting infrastructure.
## Architecture ## Architecture
@@ -13,23 +10,17 @@ service, Docker Engine for future Orb sandboxes, and supporting infrastructure.
│ ~12 CPU cores · ~40 GB RAM · single-node │ │ ~12 CPU cores · ~40 GB RAM · single-node │
│ │ │ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ zopu-daemon │ │ zopu-agent │ │ Docker │ │ │ │ zopu-web │ │ zopu-agent │ │ Docker │ │
│ │ (systemd) │ │ (systemd) │ │ Engine │ │ │ │ (systemd) │ │ (systemd) │ │ Engine │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ Effect daemon│ │ Flue Node │ │ Orb sandboxes│ │ │ │ React Router │ │ Flue Node 22 │ │ Convex │ │
│ │ + RivetKit │ │ server.mjs │ │ (future) │ │ │ │ :13100 │ │ server.mjs │ │ backend │ │
│ │ in-process │ │ :3583 │ │ │ │ │ │ │ │ :3583 │ │ :3210/:3211 │ │
│ │ engine │ │ │ │ │ │
│ │ + native │ │ │ │ │ │
│ │ sidecar │ │ │ │ │ │
│ │ :6420 │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │ │ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
│ │ │ │ │ │ │ │
└─────────────────── ─────────────
│ zopu-daemon │ Bun + Effect + RivetKit :6420
────────▼───────── ──────────────
│ │ Convex (cloud) │ ← control plane (pre-deployed)│
│ └──────────────────┘ │
│ │ │ │
│ systemd timers: health-check (60s), docker-cleanup (daily) │ │ systemd timers: health-check (60s), docker-cleanup (daily) │
│ cron: disk-monitor (daily 06:00) │ │ cron: disk-monitor (daily 06:00) │
@@ -40,31 +31,15 @@ service, Docker Engine for future Orb sandboxes, and supporting infrastructure.
### Single-node RivetKit topology ### Single-node RivetKit topology
The daemon calls `registry.start()` from `rivetkit`, which boots an The daemon calls `registry.start()` from `rivetkit`, which boots an **in-process RivetKit engine** (envoy mode) backed by a **native Rust sidecar** binary (`@rivet-dev/agentos-sidecar`, platform-resolved). The engine listens on `RIVET_ENDPOINT` (default `http://localhost:6420`). The daemon then calls `createClient(RIVET_ENDPOINT)` to connect back to its own in-process engine for actor dispatch.
**in-process RivetKit engine** (envoy mode) backed by a **native Rust
sidecar** binary (`@rivet-dev/agentos-sidecar`, platform-resolved). The engine
listens on `RIVET_ENDPOINT` (default `http://localhost:6420`). The daemon then
calls `createClient(RIVET_ENDPOINT)` to connect back to its own in-process
engine for actor dispatch.
Evidence: RivetKit source `chunk-YDUQHING.js` line 4751 — Evidence: RivetKit source `chunk-YDUQHING.js` line 4751 — `DEFAULT_ENDPOINT = "http://localhost:6420"`. The `Registry.start()` method calls `#startEnvoy()``runtime.serveRegistry()` for serverful mode (Mode A). The `createClient()` function reads `RIVET_ENDPOINT` env or defaults to the same `http://localhost:6420`.
`DEFAULT_ENDPOINT = "http://localhost:6420"`. The `Registry.start()` method
calls `#startEnvoy()``runtime.serveRegistry()` for serverful mode (Mode A).
The `createClient()` function reads `RIVET_ENDPOINT` env or defaults to the
same `http://localhost:6420`.
**No separate Rivet Engine process is required.** The engine, actor envoy, and **No separate Rivet Engine process is required.** The engine, actor envoy, and sidecar all run inside the daemon process. A future multi-node deployment would externalize the engine, but that is out of scope.
sidecar all run inside the daemon process. A future multi-node deployment
would externalize the engine, but that is out of scope.
### Docker / Orb boundary ### Docker / Orb boundary
Docker Engine is installed and the `zopu` service user is in the `docker` Docker Engine is installed and the `zopu` service user is in the `docker` group. The daemon's systemd unit includes `SupplementaryGroups=docker`. However, **no Docker-backed sandbox code is currently wired**. The Orb sandbox lane contract has not landed; Docker access is provisioned now so the boundary is ready. The current agent uses the in-process AgentOS VM (Wasm/V8) sandbox, not Docker.
group. The daemon's systemd unit includes `SupplementaryGroups=docker`.
However, **no Docker-backed sandbox code is currently wired**. The Orb
sandbox lane contract has not landed; Docker access is provisioned now so the
boundary is ready. The current agent uses the in-process AgentOS VM (Wasm/V8)
sandbox, not Docker.
## Files ## Files
@@ -76,6 +51,7 @@ deploy/zopu-runtime/
├── systemd/ ├── systemd/
│ ├── zopu-daemon.service # Daemon systemd unit │ ├── zopu-daemon.service # Daemon systemd unit
│ ├── zopu-agent.service # Agent systemd unit │ ├── zopu-agent.service # Agent systemd unit
│ ├── zopu-web.service # React Router web systemd unit
│ ├── zopu-health.service # Health check oneshot │ ├── zopu-health.service # Health check oneshot
│ ├── zopu-health.timer # Health check every 60s │ ├── zopu-health.timer # Health check every 60s
│ ├── zopu-docker-cleanup.service │ ├── zopu-docker-cleanup.service
@@ -108,7 +84,7 @@ bash bootstrap.sh
nano /opt/zopu/.env nano /opt/zopu/.env
# 5. Start services: # 5. Start services:
systemctl start zopu-daemon systemctl start zopu-web zopu-daemon
sleep 3 sleep 3
systemctl start zopu-agent systemctl start zopu-agent
@@ -122,20 +98,20 @@ systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer
## Start / stop / restart ## Start / stop / restart
```bash ```bash
# Start both services # Start all services
systemctl start zopu-daemon zopu-agent systemctl start zopu-web zopu-daemon zopu-agent
# Stop both services # Stop all services
systemctl stop zopu-agent zopu-daemon systemctl stop zopu-agent zopu-daemon zopu-web
# Restart (daemon first — it owns the RivetKit engine) # Restart (daemon first — it owns the RivetKit engine)
systemctl restart zopu-daemon && sleep 3 && systemctl restart zopu-agent systemctl restart zopu-web zopu-daemon && sleep 3 && systemctl restart zopu-agent
# Enable on boot # Enable on boot
systemctl enable zopu-daemon zopu-agent systemctl enable zopu-web zopu-daemon zopu-agent
# Disable on boot # Disable on boot
systemctl disable zopu-daemon zopu-agent systemctl disable zopu-web zopu-daemon zopu-agent
``` ```
## Log inspection ## Log inspection
@@ -183,16 +159,14 @@ systemctl list-timers zopu-health.timer
``` ```
The health check probes: The health check probes:
1. `zopu-daemon` systemd unit is active 1. `zopu-daemon` systemd unit is active
2. `zopu-agent` systemd unit is active 2. `zopu-agent` systemd unit is active
3. RivetKit engine port (default 6420) accepts TCP connections 3. RivetKit engine port (default 6420) accepts TCP connections
4. Flue agent port (default 3583) accepts TCP connections 4. Flue agent port (default 3583) accepts TCP connections
5. Docker daemon responds to `docker info` 5. Docker daemon responds to `docker info`
No HTTP health endpoints are assumed. Flue does not expose one by design No HTTP health endpoints are assumed. Flue does not expose one by design (per Flue docs: "Flue does not add a health endpoint"). RivetKit's health route is internal to the registry runtime and not documented as publicly addressable on the engine endpoint.
(per Flue docs: "Flue does not add a health endpoint"). RivetKit's health
route is internal to the registry runtime and not documented as
publicly addressable on the engine endpoint.
## Update to commit ## Update to commit
@@ -208,6 +182,7 @@ publicly addressable on the engine endpoint.
``` ```
The update script: The update script:
1. Records current HEAD to `.last-deployed-sha` 1. Records current HEAD to `.last-deployed-sha`
2. Fetches, resolves branch-or-commit, checks out 2. Fetches, resolves branch-or-commit, checks out
3. `bun install`, builds daemon and agent 3. `bun install`, builds daemon and agent
@@ -224,9 +199,7 @@ The update script:
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh abc123def456 /opt/zopu/deploy/zopu-runtime/scripts/rollback.sh abc123def456
``` ```
Rollback reads `.last-deployed-sha` (written by `update.sh`), checks out that Rollback reads `.last-deployed-sha` (written by `update.sh`), checks out that commit, rebuilds, and restarts services. The pre-rollback SHA is saved to `.pre-rollback-sha` for re-rollback if needed.
commit, rebuilds, and restarts services. The pre-rollback SHA is saved to
`.pre-rollback-sha` for re-rollback if needed.
## Docker cleanup ## Docker cleanup
@@ -242,11 +215,11 @@ systemctl list-timers zopu-docker-cleanup.timer
``` ```
Cleanup prunes: Cleanup prunes:
- Stopped containers older than 24 hours - Stopped containers older than 24 hours
- Dangling (untagged) images - Dangling (untagged) images
- Unused networks - Unused networks
Named volumes and running containers are never removed. Named volumes and running containers are never removed.
## Disk-space monitoring ## Disk-space monitoring
@@ -259,69 +232,71 @@ Named volumes and running containers are never removed.
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh --warn-percent 90 /opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh --warn-percent 90
``` ```
A cron job runs at 06:00 daily and writes to `/var/log/zopu/disk-monitor.log`. A cron job runs at 06:00 daily and writes to `/var/log/zopu/disk-monitor.log`. Default alert threshold is 80%.
Default alert threshold is 80%.
## Firewall and private networking ## Firewall and private networking
The firewall (`ufw`) is deny-by-default: The firewall (`ufw`) is deny-by-default:
- SSH (port 22) is allowed on all interfaces - SSH (port 22) is allowed on all interfaces
- All traffic on `tailscale0` is allowed (Tailscale private overlay) - All traffic on `tailscale0` is allowed (Tailscale private overlay)
- All other incoming traffic is denied - All other incoming traffic is denied
The RivetKit engine (`:6420`) and Flue agent (`:3583`) ports are **not** The RivetKit engine (`:6420`) and Flue agent (`:3583`) ports are **not** exposed on public interfaces. Reachability options:
exposed on public interfaces. Reachability options:
1. **Tailscale** (recommended): bootstrap runs `ufw allow in on tailscale0` 1. **Tailscale** (recommended): bootstrap runs `ufw allow in on tailscale0` so all ports are reachable over the private overlay. Set `TAILSCALE_AUTHKEY` before running bootstrap to configure automatically. Other Tailscale-connected machines can reach the agent at `http://zopu-runtime:3583` and the engine at `http://zopu-runtime:6420`.
so all ports are reachable over the private overlay. Set 2. **Custom private interface**: if you have a non-Tailscale private network (e.g. a VLAN or wireguard interface), add an explicit rule:
`TAILSCALE_AUTHKEY` before running bootstrap to configure automatically.
Other Tailscale-connected machines can reach the agent at
`http://zopu-runtime:3583` and the engine at `http://zopu-runtime:6420`.
2. **Custom private interface**: if you have a non-Tailscale private network
(e.g. a VLAN or wireguard interface), add an explicit rule:
```bash ```bash
ufw allow in on eth1 # or your private interface name ufw allow in on eth1 # or your private interface name
``` ```
Do NOT assume direct private IP access works by default — the deny-incoming Do NOT assume direct private IP access works by default — the deny-incoming policy blocks it until an interface-specific rule is added.
policy blocks it until an interface-specific rule is added. 3. **Caddy** (optional): install Caddy and use the annotated Caddyfile in `caddy/` if you need TLS termination or a public ingress point.
3. **Caddy** (optional): install Caddy and use the annotated Caddyfile in
`caddy/` if you need TLS termination or a public ingress point.
## Environment groups ## Environment groups
See [`.env.template`](./.env.template) for the full annotated template. The See [`.env.template`](./.env.template) for the full annotated template. The eight required groups:
eight required groups:
| Group | Variables | | Group | Variables |
|-------|-----------| | --- | --- |
| Convex | `CONVEX_URL`, `CONVEX_SITE_URL`, `SITE_URL` | | Convex | `CONVEX_URL`, `CONVEX_SITE_URL`, `SITE_URL` |
| Gitea | `GITEA_URL`, `GITEA_TOKEN` | | Gitea | `GITEA_URL`, `GITEA_TOKEN` |
| Model gateway | `AGENT_MODEL_*` | | Model gateway | `AGENT_MODEL_*` |
| AgentOS/RivetKit | `RIVET_ENDPOINT` (optional) | | AgentOS/RivetKit | `RIVET_ENDPOINT` (optional) |
| Zopu agent | `FLUE_DB_TOKEN`, `PORT` | | Zopu agent | `FLUE_DB_TOKEN` (`zopu-agent.service` sets port 3583) |
| Daemon | `DAEMON_ID`, `DAEMON_NAME`, `DAEMON_VERSION`, `DAEMON_HEARTBEAT_MS`, `DAEMON_COMMAND_LEASE_MS` | | Daemon | `DAEMON_ID`, `DAEMON_NAME`, `DAEMON_VERSION`, `DAEMON_HEARTBEAT_MS`, `DAEMON_COMMAND_LEASE_MS` |
| Docker sandbox | group membership (no env vars) | | Docker sandbox | group membership (no env vars) |
| Service auth | `AUTH_SECRET` (if needed) | | Service auth | `AUTH_SECRET` (if needed) |
## Public single-node routes
The checked-in Caddy example assumes Cloudflare Tunnel terminates TLS and sends the four Zopu hosts to Caddy on loopback:
- `zopu.sai-onchain.me` → React Router web app on `127.0.0.1:13100`
- `zopu-api.sai-onchain.me` → Convex API on `127.0.0.1:3210`
- `zopu-site.sai-onchain.me` → Convex HTTP actions on `127.0.0.1:3211`
- `zopu-agent.sai-onchain.me` → Flue on `127.0.0.1:3583`
Self-hosted Convex state lives in the `zopu-convex-data` Docker volume. Generate the CLI admin key after the backend is healthy:
```bash
docker compose \
--env-file /opt/zopu/.env \
-f /opt/zopu/deploy/zopu-runtime/convex/docker-compose.yml \
exec backend ./generate_admin_key.sh
```
## What is NOT deployed ## What is NOT deployed
- **Web app**: the web frontend is not deployed in this lane.
- **Kubernetes**: no container orchestration. - **Kubernetes**: no container orchestration.
- **PostgreSQL for Rivet**: the in-process RivetKit engine uses its own - **PostgreSQL for Rivet**: the in-process RivetKit engine uses its own storage; no external PostgreSQL is required.
storage; no external PostgreSQL is required.
- **Multi-node coordination**: single-node only. - **Multi-node coordination**: single-node only.
- **Public administration endpoints**: no admin HTTP surface. - **Public administration endpoints**: no admin HTTP surface.
- **Secrets in source**: `.env` is never committed; `.env.template` contains - **Secrets in source**: `.env` is never committed; `.env.template` contains only placeholder values.
only placeholder values. - **Docker-backed Orb sandboxes**: Docker is installed and access is provisioned, but no Orb sandbox code is wired. This is a boundary prepared for the Orb lane, not a working feature.
- **Production generated-app hosting**: only the execution plane runs here.
- **Docker-backed Orb sandboxes**: Docker is installed and access is
provisioned, but no Orb sandbox code is wired. This is a boundary
prepared for the Orb lane, not a working feature.
## Reproducibility ## Reproducibility
The deployment does not require the developer's MacBook to remain online. The deployment does not require the developer's MacBook to remain online. Once bootstrap completes and `.env` is filled in:
Once bootstrap completes and `.env` is filled in:
1. Services run under systemd with `Restart=always`. 1. Services run under systemd with `Restart=always`.
2. Logs persist in journald. 2. Logs persist in journald.

View File

@@ -14,9 +14,9 @@
# TAILSCALE_AUTHKEY — if set, configure Tailscale # TAILSCALE_AUTHKEY — if set, configure Tailscale
# TAILSCALE_HOSTNAME — Tailscale hostname (default: zopu-runtime) # TAILSCALE_HOSTNAME — Tailscale hostname (default: zopu-runtime)
# #
# Installs: Docker Engine, Bun, clones the repo, runs bun install, builds the # Installs: Docker Engine, Node.js 22, Bun, clones the repo, runs bun install, builds the
# daemon and agent, creates a non-root service user, installs systemd units, # web app, daemon, and agent, creates a non-root service user, installs systemd
# and configures firewall/Tailscale defaults. # units, and configures firewall/Tailscale defaults.
set -euo pipefail set -euo pipefail
@@ -105,18 +105,31 @@ fi
systemctl enable --now docker systemctl enable --now docker
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 3. Bun # 3. Node.js 22 (required by the Flue Node target)
# ---------------------------------------------------------------------------
NODE_MAJOR=$(node --version 2>/dev/null | sed -n 's/^v\([0-9][0-9]*\).*/\1/p')
if [[ -z "$NODE_MAJOR" || "$NODE_MAJOR" -lt 22 ]]; then
log "Installing Node.js 22..."
curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh
bash /tmp/nodesource_setup.sh
apt-get install -y nodejs
else
log "Node.js already installed: $(node --version)"
fi
# ---------------------------------------------------------------------------
# 4. Bun
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if ! command -v bun &>/dev/null; then if ! command -v bun &>/dev/null; then
log "Installing Bun..." log "Installing Bun..."
curl -fsSL https://bun.sh/install | bash curl -fsSL https://bun.sh/install | bash
ln -sf /root/.bun/bin/bun /usr/local/bin/bun install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
else else
log "Bun already installed: $(bun --version)" log "Bun already installed: $(bun --version)"
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4. Service user # 5. Service user
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if ! id "$SERVICE_USER" &>/dev/null; then if ! id "$SERVICE_USER" &>/dev/null; then
log "Creating service user: $SERVICE_USER" log "Creating service user: $SERVICE_USER"
@@ -129,7 +142,7 @@ if ! id -nG "$SERVICE_USER" | grep -qw docker; then
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5. Clone or update repository # 6. Clone or update repository
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [[ -d "$INSTALL_DIR/.git" ]]; then if [[ -d "$INSTALL_DIR/.git" ]]; then
log "Repository exists at $INSTALL_DIR, fetching latest..." log "Repository exists at $INSTALL_DIR, fetching latest..."
@@ -144,25 +157,28 @@ else
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5b. Hand ownership of the checkout to the service user # 6b. Hand ownership of the checkout to the service user
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "Setting ownership of $INSTALL_DIR to $SERVICE_USER..." log "Setting ownership of $INSTALL_DIR to $SERVICE_USER..."
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR" chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 6. Install dependencies and build # 7. Install dependencies and build
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "Running bun install..." log "Running bun install..."
run_as_service bun install run_as_service bun install
log "Building daemon binary..." log "Building web app..."
run_as_service bun run --cwd apps/web build
log "Validating daemon production build..."
run_as_service bun run build:daemon run_as_service bun run build:daemon
log "Building agent service..." log "Building agent service..."
run_as_service bun run build:agents run_as_service bun run build:agents
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 7. Environment file # 8. Environment file
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
ENV_FILE="$INSTALL_DIR/.env" ENV_FILE="$INSTALL_DIR/.env"
if [[ ! -f "$ENV_FILE" ]]; then if [[ ! -f "$ENV_FILE" ]]; then
@@ -179,17 +195,17 @@ else
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 8. Persistent log directory # 9. Persistent log directory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
LOG_DIR="/var/log/zopu" LOG_DIR="/var/log/zopu"
mkdir -p "$LOG_DIR" mkdir -p "$LOG_DIR"
chown "$SERVICE_USER":"$SERVICE_USER" "$LOG_DIR" chown "$SERVICE_USER":"$SERVICE_USER" "$LOG_DIR"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 9. Install systemd units (substitute placeholders) # 10. Install systemd units (substitute placeholders)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "Installing systemd units..." log "Installing systemd units..."
for unit in zopu-daemon.service zopu-agent.service \ for unit in zopu-web.service zopu-daemon.service zopu-agent.service \
zopu-health.timer zopu-health.service \ zopu-health.timer zopu-health.service \
zopu-docker-cleanup.timer zopu-docker-cleanup.service; do zopu-docker-cleanup.timer zopu-docker-cleanup.service; do
SRC="$DEPLOY_DIR/systemd/$unit" SRC="$DEPLOY_DIR/systemd/$unit"
@@ -205,7 +221,7 @@ done
systemctl daemon-reload systemctl daemon-reload
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 10. Firewall (deny-by-default, explicit allow for Tailscale) # 11. Firewall (deny-by-default, explicit allow for Tailscale)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "Configuring firewall..." log "Configuring firewall..."
if ! ufw status 2>/dev/null | grep -q "Status: active"; then if ! ufw status 2>/dev/null | grep -q "Status: active"; then
@@ -227,7 +243,7 @@ else
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 11. Tailscale (optional) # 12. Tailscale (optional)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [[ -n "${TAILSCALE_AUTHKEY:-}" ]]; then if [[ -n "${TAILSCALE_AUTHKEY:-}" ]]; then
log "Installing and configuring Tailscale..." log "Installing and configuring Tailscale..."
@@ -249,7 +265,7 @@ else
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 12. Disk-space monitoring cron # 13. Disk-space monitoring cron
# /etc/cron.d format REQUIRES a username field. # /etc/cron.d format REQUIRES a username field.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "Installing disk-space monitor (daily at 06:00)..." log "Installing disk-space monitor (daily at 06:00)..."
@@ -265,7 +281,7 @@ echo ""
echo "Next steps:" echo "Next steps:"
echo " 1. Edit $ENV_FILE with real values" echo " 1. Edit $ENV_FILE with real values"
echo " 2. Start services:" echo " 2. Start services:"
echo " systemctl start zopu-daemon zopu-agent" echo " systemctl start zopu-web zopu-daemon zopu-agent"
echo " 3. Enable health monitoring:" echo " 3. Enable health monitoring:"
echo " systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer" echo " systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer"
echo " 4. Verify health:" echo " 4. Verify health:"

View File

@@ -1,15 +1,15 @@
# Caddyfile — Public reverse proxy for the Zopu chat + API. # Caddyfile — Public reverse proxy for the Zopu web + API.
# #
# The web frontend (port 5174) is served at the root, and the agent/API # The web frontend (port 5173) is served at the root, and the Flue agent/API
# (port 3590) is mounted under /api so the browser talks same-origin. # (port 3585) is mounted under /api so the browser talks same-origin.
zopu.cheaptricks.puter.wtf { zopu.cheaptricks.puter.wtf {
bind 135.181.82.179 2a01:4f9:c013:4a64::1 bind 135.181.82.179 2a01:4f9:c013:4a64::1
encode zstd gzip encode zstd gzip
handle_path /api/* { handle_path /api/* {
reverse_proxy 127.0.0.1:3590 reverse_proxy 127.0.0.1:3585
} }
reverse_proxy 127.0.0.1:5174 reverse_proxy 127.0.0.1:5173
} }

View File

@@ -0,0 +1,25 @@
{
admin 127.0.0.1:2019
auto_https off
http_port 8080
}
http://zopu.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:13100
}
http://zopu-api.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3210
}
http://zopu-site.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3211
}
http://zopu-agent.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3583
}

View File

@@ -0,0 +1,13 @@
tunnel: 9e54ac9c-c1a1-4583-be08-3b5f1acc7b65
credentials-file: /root/.cloudflared/9e54ac9c-c1a1-4583-be08-3b5f1acc7b65.json
ingress:
- hostname: zopu.sai-onchain.me
service: http://127.0.0.1:8080
- hostname: zopu-api.sai-onchain.me
service: http://127.0.0.1:8080
- hostname: zopu-site.sai-onchain.me
service: http://127.0.0.1:8080
- hostname: zopu-agent.sai-onchain.me
service: http://127.0.0.1:8080
- service: http_status:404

View File

@@ -0,0 +1,32 @@
services:
backend:
image: ghcr.io/get-convex/convex-backend:latest
container_name: zopu-convex
restart: unless-stopped
stop_grace_period: 10s
stop_signal: SIGINT
ports:
- "127.0.0.1:3210:3210"
- "127.0.0.1:3211:3211"
volumes:
- zopu-convex-data:/convex/data
environment:
CONVEX_CLOUD_ORIGIN: ${CONVEX_CLOUD_ORIGIN}
CONVEX_SITE_ORIGIN: ${CONVEX_SITE_ORIGIN}
DISABLE_BEACON: "true"
DISABLE_METRICS_ENDPOINT: "true"
DOCUMENT_RETENTION_DELAY: "172800"
INSTANCE_NAME: ${CONVEX_INSTANCE_NAME:-zopu-production}
INSTANCE_SECRET: ${CONVEX_INSTANCE_SECRET:-}
REDACT_LOGS_TO_CLIENT: "true"
RUST_LOG: ${CONVEX_RUST_LOG:-info}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3210/version"]
interval: 5s
timeout: 3s
retries: 12
start_period: 10s
volumes:
zopu-convex-data:
name: zopu-convex-data

View File

@@ -72,7 +72,10 @@ fi
log "Running bun install..." log "Running bun install..."
run_as_service bun install run_as_service bun install
log "Building daemon..." log "Building web app..."
run_as_service bun run --cwd apps/web build
log "Validating daemon production build..."
run_as_service bun run build:daemon run_as_service bun run build:daemon
log "Building agent service..." log "Building agent service..."
@@ -82,6 +85,7 @@ log "Restarting services..."
systemctl restart zopu-daemon systemctl restart zopu-daemon
sleep 3 sleep 3
systemctl restart zopu-agent systemctl restart zopu-agent
systemctl restart zopu-web
sleep 5 sleep 5
# Health check # Health check
@@ -94,6 +98,7 @@ if [[ -x "$HEALTH_SCRIPT" ]]; then
err "Health check failed after rollback!" err "Health check failed after rollback!"
err " journalctl -u zopu-daemon -n 50" err " journalctl -u zopu-daemon -n 50"
err " journalctl -u zopu-agent -n 50" err " journalctl -u zopu-agent -n 50"
err " journalctl -u zopu-web -n 50"
exit 1 exit 1
fi fi
fi fi

View File

@@ -77,17 +77,21 @@ fi
log "Running bun install..." log "Running bun install..."
run_as_service bun install run_as_service bun install
log "Building daemon binary..." log "Building web app..."
run_as_service bun run --cwd apps/web build
log "Validating daemon production build..."
run_as_service bun run build:daemon run_as_service bun run build:daemon
log "Building agent service..." log "Building agent service..."
run_as_service bun run build:agents run_as_service bun run build:agents
# Graceful restart: daemon first (owns RivetKit engine), then agent # Graceful restart: daemon first (owns RivetKit engine), then agent and web
log "Restarting services..." log "Restarting services..."
systemctl restart zopu-daemon systemctl restart zopu-daemon
sleep 3 sleep 3
systemctl restart zopu-agent systemctl restart zopu-agent
systemctl restart zopu-web
sleep 5 sleep 5
# Health check # Health check
@@ -100,6 +104,7 @@ if [[ -x "$HEALTH_SCRIPT" ]]; then
err "Health check failed after update!" err "Health check failed after update!"
err " journalctl -u zopu-daemon -n 50" err " journalctl -u zopu-daemon -n 50"
err " journalctl -u zopu-agent -n 50" err " journalctl -u zopu-agent -n 50"
err " journalctl -u zopu-web -n 50"
err "To rollback: ${INSTALL_DIR}/deploy/zopu-runtime/scripts/rollback.sh" err "To rollback: ${INSTALL_DIR}/deploy/zopu-runtime/scripts/rollback.sh"
exit 1 exit 1
fi fi

View File

@@ -10,10 +10,11 @@ Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__/packages/agents WorkingDirectory=__INSTALL_DIR__/packages/agents
EnvironmentFile=__INSTALL_DIR__/.env EnvironmentFile=__INSTALL_DIR__/.env
Environment=PORT=3583
# Flue build output: packages/agents/dist/server.mjs # Flue's Node target requires Node built-ins such as node:sqlite.
# Listens on PORT (default 3583 per .env.template). # Listens on the service-pinned port 3583.
ExecStart=/usr/local/bin/bun dist/server.mjs ExecStart=/usr/bin/node dist/server.mjs
Restart=always Restart=always
RestartSec=10 RestartSec=10

View File

@@ -10,11 +10,13 @@ Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__ WorkingDirectory=__INSTALL_DIR__
EnvironmentFile=__INSTALL_DIR__/.env EnvironmentFile=__INSTALL_DIR__/.env
Environment=HOME=/var/lib/zopu
StateDirectory=zopu
# RivetKit in-process engine: envoy mode (serverful). # RivetKit in-process engine: envoy mode (serverful).
# registry.start() boots the native Rust sidecar and actor envoy. # Run the Bun source entrypoint so AgentOS software assets retain their real
# RIVET_ENDPOINT defaults to http://localhost:6420 inside the daemon process. # node_modules paths; Bun compiled executables do not embed .aospkg files.
ExecStart=__INSTALL_DIR__/apps/daemon/dist/code-daemon ExecStart=/usr/local/bin/bun --env-file=__INSTALL_DIR__/.env apps/daemon/src/index.ts
Restart=always Restart=always
RestartSec=10 RestartSec=10

View File

@@ -0,0 +1,26 @@
[Unit]
Description=Zopu Web
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=__SERVICE_USER__
Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__/apps/web
EnvironmentFile=__INSTALL_DIR__/.env
Environment=HOST=127.0.0.1
Environment=PORT=13100
ExecStart=/usr/local/bin/bun run start
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=zopu-web
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View File

@@ -1,420 +0,0 @@
# Signals Backend Implementation Plan
> **For OMP:** Create one isolated task worktree per implementation task. Run dependent tasks serially, merge each completed task branch into `feat/signals`, resolve integration conflicts on that branch, and verify the merged result before dispatching the next dependent task.
**Goal:** Build the first product-grade Signal slice: the authenticated global agent turns one or more exact user messages into one durable, Effect-modeled Signal containing immutable raw evidence and a separate processed problem statement.
**Architecture:** Keep the Signal domain pure in `@code/primitives`; keep authorization, idempotency, storage, and source resolution in Convex; keep conversation transport and the agent decision in Flue. The global Zopu agent becomes organization-scoped and receives two narrow tools: list eligible user-message sources, then create a Signal from selected message IDs and a processed problem statement. AgentOS remains downstream execution infrastructure and is not involved in Signal capture.
**Tech Stack:** Bun workspaces/catalogs, TypeScript, Effect v4, Convex, Flue, Hono, Better Auth, Vitest, convex-test.
---
## Current findings and decisions
1. `PRODUCT.md` separates **Signal**, **Candidate Work**, and **Work Unit**. This slice must not create candidate, plan, priority, run, or execution fields.
2. `TECH.md` requires explicit organization scope, exact user-message persistence, raw-evidence/agent-interpretation separation, and source links.
3. The current global chat is `zopu/main`, so every user shares one Flue instance. Flue routes are pass-through and the browser sends no identity. This must be corrected before Signals can be safe.
4. Canonical Flue `user_message` records already contain exact text plus `messageId`, `conversationId`, timestamp, and `submissionId`, but Convex stores them inside opaque JSON batches. Product commands should not parse those batches ad hoc.
5. Existing `projectSignals` is an execution event feed, not the product Signal entity. Rename it to `projectEvents` before introducing `signals`.
6. The Signal primitive will use Effect schemas and a pure Effect constructor. It will not contain a repository service or depend on Convex, Flue, Hono, AgentOS, or generated IDs.
7. A Signal is created only when the global agent decides the conversation contains a coherent problem statement. Individual user messages remain conversation evidence until then.
8. One Signal may compose multiple ordered user messages. Raw text remains per-message and exact; `problemStatement` is the agent-produced interpretation.
9. Signal creation is idempotent by organization + conversation + ordered source-message IDs so Flue retries cannot create duplicates.
10. Use a minimal organization foundation because `ownerId` is a user identifier, not the hard tenancy boundary required by the product. Do not build full organization administration in this slice.
## Domain model
```ts
interface Signal {
readonly id: SignalId;
readonly scope:
| { readonly _tag: "Organization"; readonly organizationId: OrganizationId }
| {
readonly _tag: "Project";
readonly organizationId: OrganizationId;
readonly projectId: ProjectId;
};
readonly sourceMessages: readonly [SignalSourceMessage, ...SignalSourceMessage[]];
readonly problemStatement: ProblemStatement;
readonly processedBy: {
readonly agentName: string;
readonly agentInstanceId: string;
};
readonly createdAt: TimestampMs;
}
interface SignalSourceMessage {
readonly conversationId: ConversationId;
readonly messageId: ConversationMessageId;
readonly rawText: string; // exact; never trim or rewrite
readonly createdAt: TimestampMs;
}
interface ProblemStatement {
readonly title: string;
readonly summary: string;
readonly desiredOutcome: string;
readonly constraints: readonly string[];
}
```
**Invariants:**
- Every Signal has exactly one organization scope and zero or one project scope.
- `sourceMessages` is non-empty, ordered, and unique by `(conversationId, messageId)`.
- Every source is a user message from the same organization-scoped global conversation.
- `rawText` is copied server-side from the stored source message; the agent never supplies it.
- `problemStatement.title`, `summary`, and `desiredOutcome` are non-empty; constraints are structured as individual non-empty items and remain distinct from raw evidence.
- Signal does not contain candidate/work-unit/run fields.
- IDs are branded non-empty strings in primitives; adapters translate Convex/Flue IDs.
- Timestamps are non-negative integer epoch milliseconds.
## Persistence model
Add these application tables in `packages/backend/convex/schema.ts`:
- `organizations`: minimal durable tenant record with `name`, `createdBy`, `createdAt`.
- `organizationMembers`: `organizationId`, `userId` (`identity.tokenIdentifier`), `role`, `createdAt`; indexes by user and organization/user.
- `conversationMessages`: normalized user-message evidence with `organizationId`, `conversationId`, `messageId`, `submissionId?`, `clientRequestId`, `role: "user"`, exact `rawText`, `status: "admitting" | "admitted" | "failed"`, and `createdAt`; unique lookup indexes by organization/message and organization/client request.
- `signals`: `organizationId`, optional `projectId`, `sourceKey`, structured `problemStatement` (`title`, `summary`, `desiredOutcome`, `constraints`), `processedByAgentName`, `processedByAgentInstanceId`, `createdAt`; indexes by organization/createdAt, project/createdAt, and organization/sourceKey.
- `signalSources`: `signalId`, `organizationId`, `conversationId`, `messageId`, `ordinal`, `rawTextSnapshot`, `sourceCreatedAt`, optional `submissionId`; indexes by signal/ordinal and organization/message.
- `projectEvents`: the renamed current execution event feed.
Do not add lifecycle/candidate status to `signals` yet. A later Candidate component will own promotion and relationships.
---
### Task 1: Normalize the workspace dependency catalog
**Objective:** Centralize every dependency repeated across workspace manifests without forcing intentionally different platform versions into one range.
**Files:**
- Modify: `package.json`
- Modify: `apps/daemon/package.json`
- Modify: other `apps/*/package.json` and `packages/*/package.json` entries identified by the catalog audit
- Modify: `bun.lock` through `bun install`
**Steps:**
1. Add default catalog entries for shared versions that should converge: `effect`, AgentOS packages, `typescript`, `@types/bun`, `@types/react`, `heroui-native`, `vite`, `vitest`, and `convex-test`.
2. Add named catalogs for intentional platform splits, especially Node type majors and React/React Native compatibility ranges. Use `catalog:<name>` rather than silently upgrading Expo/native consumers.
3. Replace direct Effect and AgentOS pins in `apps/daemon/package.json` with `catalog:`.
4. Replace repeated direct versions in workspace manifests with their default or named catalog reference. Leave peer dependency compatibility ranges as peer ranges when they intentionally describe supported versions rather than installation versions.
5. Run `bun install` and verify the lockfile resolves the same intended platform families.
**Acceptance:**
- No repeated installed dependency version remains scattered across manifests without an explicit reason.
- Effect v4 and AgentOS resolve from the root catalog everywhere.
- Native React remains on its Expo-compatible exact family; web/TUI ranges are not accidentally collapsed.
**Verification:**
```bash
bun install
bun run check-types
```
---
### Task 2: Add the pure Effect Signal primitive
**Objective:** Establish the portable Signal vocabulary and validation rules before storage or agents depend on it.
**Files:**
- Create: `packages/primitives/src/signal.ts`
- Create: `packages/primitives/src/signal.test.ts`
- Modify: `packages/primitives/src/index.ts`
- Modify: `packages/primitives/package.json`
**Implementation:**
- Define branded schemas for `SignalId`, `OrganizationId`, `ProjectId`, `ConversationId`, `ConversationMessageId`, and `TimestampMs`.
- Define `SignalScope`, `SignalSourceMessage`, `ProblemStatement`, `ProcessedByAgent`, `SignalInput`, and `Signal` with `Schema.Struct` / discriminated unions.
- Add `SignalValidationError` with `Schema.TaggedErrorClass`.
- Add `composeSignal = Effect.fn("Signal.compose")` that decodes input, rejects duplicate/out-of-order sources, preserves raw whitespace exactly, and returns a validated Signal.
- Export the module from the root and an explicit `./signal` package subpath.
- Add a package `test` script using cataloged Vitest.
**Tests:**
- Accept organization and project scopes.
- Reject empty IDs, empty source list, empty problem-statement title/summary/desired outcome, empty constraint items, negative/fractional timestamps, duplicate source IDs, mixed conversations, and out-of-order timestamps.
- Prove raw leading/trailing whitespace and source ordering survive unchanged.
- Prove candidate/work-unit/run fields are absent from the decoded result.
**Verification:**
```bash
bun run --cwd packages/primitives test
bun run --cwd packages/primitives check-types
bunx ultracite check packages/primitives/src/signal.ts packages/primitives/src/signal.test.ts packages/primitives/src/index.ts
```
---
### Task 3: Establish minimal organization tenancy
**Objective:** Give every durable Signal and global conversation a real tenant boundary instead of reusing a user ID or `main`.
**Files:**
- Modify: `packages/backend/convex/schema.ts`
- Modify: `packages/backend/convex/authz.ts`
- Create: `packages/backend/convex/organizations.ts`
- Create: `packages/backend/convex/organizations.test.ts`
**Implementation:**
- Add `organizations` and `organizationMembers` tables.
- Add `ensurePersonalOrganization`, idempotently creating one personal organization and owner membership for the authenticated user.
- Add `getCurrent` and `requireOrganizationMember` helpers.
- Keep full organization invitations, multiple active organizations, roles beyond owner/member, and organization settings out of scope.
- Ensure every query/mutation denies access without both authenticated identity and membership.
**Tests:**
- First ensure creates one organization and owner membership.
- Repeated ensure returns the same organization.
- A second identity cannot read or mutate the first identity's organization.
**Verification:**
```bash
bun run --cwd packages/backend test -- organizations
bun run --cwd packages/backend check-types
```
---
### Task 4: Rename execution signals to project events
**Objective:** Remove the naming collision before creating the product `signals` table.
**Files:**
- Modify: `packages/backend/convex/schema.ts`
- Modify: `packages/backend/convex/projects.ts`
- Modify: `packages/backend/convex/projectIssues.ts`
- Modify: `packages/backend/convex/agentWorkspace.ts`
- Modify: `packages/backend/convex/artifactModel.ts`
- Modify: `packages/backend/convex/README.md`
- Optional migration file only if existing rows are present: `packages/backend/convex/migrations.ts`
**Steps:**
1. Inspect the active Convex deployment count for `projectSignals` before changing schema.
2. If empty, cleanly rename the table and call sites to `projectEvents`.
3. If non-empty, add a one-off internal migration that copies rows with preserved relations/timestamps, verify source/destination counts, then remove the old schema/table references. Do not leave a compatibility alias.
4. Update `signals.md` seed copy to say “project events,” not product Signals.
**Acceptance:**
- `projectSignals` no longer exists in source or schema.
- Existing project event behavior remains unchanged.
- Product `signals` can be introduced without semantic ambiguity.
**Verification:**
```bash
bun run --cwd packages/backend check-types
```
Exercise project connect, issue create/start, artifact update, and agent status once and confirm `projectEvents` receives the same events.
---
### Task 5: Normalize authenticated global user messages
**Objective:** Persist exact tenant-scoped user evidence at the conversation ingress before the agent can create Signals.
**Files:**
- Modify: `packages/backend/convex/schema.ts`
- Create: `packages/backend/convex/conversationMessages.ts`
- Create: `packages/backend/convex/conversationMessages.test.ts`
- Modify: `packages/agents/src/agents/zopu.ts`
- Create: `packages/agents/src/auth.ts`
- Modify: `packages/agents/package.json`
- Modify: `apps/web/src/root.tsx`
- Modify: `apps/web/src/lib/chat/constants.ts`
- Modify: `apps/web/src/hooks/chat/use-chat-agent.ts`
- Modify: `packages/auth/src/web/index.ts` only if the access-token helper is not already exported
**Ingress sequence:**
1. Web obtains the Better Auth/Convex access token with the installed `fetchAccessToken` API.
2. The custom Flue fetch adds `Authorization: Bearer <token>` and a stable `x-zopu-request-id` to agent POSTs.
3. Zopu route middleware requires the bearer token, sets it on a short-lived `ConvexHttpClient`, resolves current organization membership, and rejects an agent instance ID that is not that organization ID.
4. Before `next()`, middleware clones and validates the direct payload and calls `conversationMessages.beginUserMessage` with exact text and request ID.
5. After Flue returns its admission receipt, middleware patches `submissionId` and status `admitted`; on failure it marks the message `failed` and rethrows/returns the original failure.
6. The web global agent ID becomes the current organization ID rather than `main`.
**Important constraints:**
- Do not trust organization ID, raw text, timestamp, or source role from an agent tool.
- Do not parse `flueConversationBatches.recordsJson` for product commands.
- Do not persist auth tokens.
- Keep failed admissions as evidence with explicit status; do not silently delete them.
- Ensure GET/SSE observation routes use the same organization authorization.
**Tests:**
- Unauthorized Flue route is rejected.
- Organization A cannot use or observe organization B's agent ID.
- Duplicate request ID is idempotent.
- Exact whitespace/casing is stored.
- Success records submission ID; failure records failed status.
**Verification:**
- Send one message containing leading/trailing whitespace through the running web app.
- Confirm the agent replies.
- Query Convex and confirm the normalized user message has exact raw text, organization scope, conversation/message identity, request ID, and admitted submission ID.
- Sign in as another identity and confirm it cannot observe the first conversation.
---
### Task 6: Persist Signals atomically in Convex
**Objective:** Resolve selected message IDs server-side, run the Effect constructor, and atomically insert one idempotent Signal plus ordered source snapshots.
**Files:**
- Modify: `packages/backend/convex/schema.ts`
- Create: `packages/backend/convex/signals.ts`
- Create: `packages/backend/convex/signals.test.ts`
- Modify: `packages/backend/package.json`
**Interface:**
```ts
createFromMessages({
organizationId,
projectId?,
conversationId,
messageIds,
problemStatement: { title, summary, desiredOutcome, constraints },
processedBy: { agentName, agentInstanceId },
}) -> signalId
```
**Implementation:**
- Expose an agent-token-gated mutation for Zopu and authenticated read queries for product clients.
- Resolve every message from `conversationMessages` under the same organization and conversation.
- Require role `user`, status `admitted`, non-empty ordered selection, and optional project ownership by the same organization.
- Copy raw text/timestamps/submission IDs into `signalSources`; never accept snapshots from the agent.
- Build `sourceKey` deterministically from organization, conversation, and ordered message IDs.
- Run `composeSignal` from `@code/primitives/signal` before writes.
- In one Convex mutation, return an existing row for the same source key or insert `signals` and all `signalSources` in ordinal order.
- Add bounded `list` and `get` queries that enforce membership and return the composed source records.
**Tests:**
- Creates one global Signal from one user message.
- Composes several ordered user messages into one Signal.
- Preserves exact raw snapshots while storing a separate structured problem statement.
- Rejects cross-organization, cross-conversation, assistant, failed, missing, duplicated, and unauthorized project messages.
- Repeating the same source set returns the existing Signal without duplicate source rows.
**Verification:**
```bash
bun run --cwd packages/backend test -- signals
bun run --cwd packages/backend check-types
bunx ultracite check packages/backend/convex/schema.ts packages/backend/convex/signals.ts packages/backend/convex/signals.test.ts
```
---
### Task 7: Let Zopu decide and create Signals
**Objective:** Give the global agent a narrow, validated mechanism to inspect eligible user evidence and create a Signal when the problem statement is coherent.
**Files:**
- Create: `packages/agents/src/tools/signals.ts`
- Modify: `packages/agents/src/agents/zopu.ts`
- Modify: `packages/agents/package.json`
- Create: `packages/agents/scripts/signal-smoke.ts`
**Tools:**
1. `list_signal_source_messages`
- No free tenant input; organization comes from the authenticated agent instance ID.
- Returns a bounded list of admitted user messages not already used by a Signal, including message ID, exact text, and timestamp.
2. `create_signal`
- Input: ordered message IDs, structured problem statement (`title`, `summary`, `desiredOutcome`, `constraints`), optional project ID.
- Calls the Convex `createFromMessages` mutation with `agentName: "zopu"` and the current organization-scoped instance ID.
- Returns only the Signal ID and source count.
**Agent instruction:**
- Continue normal planning conversation until the user's instructions form a coherent problem statement.
- Do not create a Signal for greetings, exploration without a concrete problem, or every individual message.
- When ready, call `list_signal_source_messages`, select only the messages composing the problem, then call `create_signal` once.
- Tell the user the captured problem-statement title, summary, desired outcome, and constraints.
- Do not create Candidate Work or start AgentOS in this slice.
**Retry safety:**
- Flue may replay tool calls after interruption. Convex source-key idempotency is authoritative.
- The tool must surface domain errors; it must not swallow or convert them into fake success.
**Verification:**
Run the focused smoke script and a live browser scenario:
1. Send three messages: an initial problem, a clarification, and an explicit final constraint.
2. Observe Zopu create exactly one Signal after the statement is coherent.
3. Confirm the Signal contains all selected raw messages in order and one structured problem statement with title, summary, desired outcome, and constraints.
4. Restart the agent service/retry the final turn and confirm no duplicate Signal is inserted.
5. Confirm no AgentOS actor, candidate, work unit, issue, or run is created.
```bash
bun run --cwd packages/agents signal:smoke
bun run --cwd packages/agents check-types
bun run --cwd apps/web check-types
bunx ultracite check packages/agents/src/tools/signals.ts packages/agents/src/agents/zopu.ts packages/agents/scripts/signal-smoke.ts apps/web/src/root.tsx apps/web/src/lib/chat/constants.ts apps/web/src/hooks/chat/use-chat-agent.ts
```
---
### Task 8: Final verification and cleanup
**Objective:** Prove the complete Signal slice works and remove temporary migration/scaffolding.
**Steps:**
1. Run all focused tests and smoke scenarios from Tasks 27.
2. Run Ultracite fix if necessary, then check every changed source/script/agent file.
3. Run affected package type checks: primitives, backend, agents, auth if touched, and web.
4. Run root `bun run check` and report unrelated pre-existing failures separately.
5. Remove a one-off `projectSignals` migration only after row-count verification and successful cutover, unless the repository convention keeps completed migrations.
6. Update existing backend/agent documentation only where names or commands became stale; do not add speculative docs.
**Final acceptance criteria:**
- Global conversations are organization-scoped and authenticated; `zopu/main` is gone.
- Exact user text is stored before/with Flue admission and cannot cross organizations.
- Zopu can intentionally compose one or more user messages into exactly one durable Signal.
- Signal raw evidence and the structured processed problem statement are separate and queryable.
- Source provenance is immutable and ordered.
- Retry produces no duplicate Signal.
- Existing project execution events still work under `projectEvents`.
- Signal creation does not create Candidate Work, Work Unit, run, or AgentOS resources.
- Effect v4 and AgentOS dependency versions come from workspace catalogs.
- Focused tests, package type checks, runtime smoke, and root check are reported with evidence.
## Risks and deliberate tradeoffs
- **Minimal organization bootstrap:** This adds only the tenancy needed for Signals. Multi-org switching, invites, billing, and policy are separate components.
- **Normalized user evidence plus Flue transcript:** There are temporarily two durable representations. Flue remains the canonical conversational transcript; `conversationMessages` is the application-domain evidence index required for authorization and provenance. Idempotent request IDs and submission links keep them correlated.
- **No automatic Signal for every message:** This follows the product thesis and avoids task noise. The agent explicitly decides when the instructions are coherent.
- **No separate Flow agent yet:** Zopu performs the first extraction using narrow tools. A future Flow Agent can adopt the same Convex command without changing the Signal primitive.
- **No AgentOS here:** AgentOS begins only after future Candidate/Work Unit readiness and scheduling.
- **No Signal status model yet:** Candidate attachment, knowledge capture, dismissal, and work promotion belong to later components. Adding them now would pre-design unimplemented workflows.

View File

@@ -1,367 +1,469 @@
# Zopu Work OS — Product Design # Zopu Work OS — Product Design
> **Status:** canonical working interaction/interface specification · **Audience:** product design, frontend/product engineering, agent experience · **Scope:** information architecture, screens, cards, interaction patterns, states, actions, principles · **Excludes:** backend and low-level infrastructure > **Related:** `product.md` defines the domain; `dev-loop.md` defines phases/gates; `slices.md` defines UI rollout.
## 1. Objective and governing model > **Status:** Working interaction specification
> **Audience:** frontend, product, agents generating UI
> **Constraint:** simple interface; complexity is revealed only when useful.
Design one calm, persistent workspace for supervising many active work units across organizations, products, projects, teams, and contexts—without making users manage chat threads, model settings, or agent sessions. It must make an autonomous system understandable and controllable by showing the minimum needed to answer: **what matters; what is happening; what is blocked; what needs human action; what happens next; why the system believes it.** ## 1. Experience principle
> **The conversation is the command surface; the work unit is the persistent product object.** The user should experience:
## 2. Experience principles
| Principle | Requirement |
| --- | --- |
| One continuous workspace | Do not organize around chat history, sessions, or thread lists. Keep one workspace and a composer that works globally or within a selected unit. |
| Work first, machinery second | Show outcomes, blockers, decisions, artifacts, results. Hide agent names, model choices, runtime and infrastructure unless they explain progress/debug failure. |
| Progressive disclosure | Collapsed cards scan quickly; expanded cards become working surfaces; evidence/raw events/technical detail are on demand. |
| Calm density | Support dense operational information without an enterprise-dashboard feel; use hierarchy, spacing, typography, grouping—not excessive borders, color, or permanent panels. |
| Human attention is scarce | Centralize approvals, blockers, unanswered questions, and other requests; users must not hunt through cards. |
| Visible context | Always show organization; project/portfolio scope; global vs work-specific composer; selected unit; whether an action affects external/production systems. |
| Nearby provenance | Important claims, decisions, and recommendations expose source indicators; evidence is one interaction away, not buried in an audit app. |
## 3. Product language
| Term | Meaning |
| --- | --- |
| **Organization** | Company, client, or hard context boundary. |
| **Project** | Product, application, repository-backed system, or durable operating context. |
| **Goal** | Measurable business/product objective. |
| **Signal** | New information that may matter. |
| **Candidate** | Proposed work unit awaiting confirmation. |
| **Work unit** | Persistent outcome the system is responsible for progressing. |
| **Run** | One execution attempt inside a work unit. |
| **Artifact** | Produced/collected output: PR, preview, report, design, document, etc. |
| **Attention item** | Decision, approval, question, blocker, review, or risk requiring human action. |
Do not expose **agent thread, model context window, harness session, VM, sandbox lifecycle, actor wake state** except in technical/debug views.
## 4. Global information architecture
Desktop uses three regions plus a persistent composer:
```text ```text
┌─────────────────────────────────────────────────────────────────────┐ Talk naturally
│ Header │ → see durable Work emerge
├───────────────┬───────────────────────────────────┬─────────────────┤ → approve what matters
│ Work index │ Primary workspace │ Context panel │ → watch meaningful progress
│ │ cards / expanded work / chat │ evidence, │ → answer precise questions
│ │ │ artifacts, │ → inspect evidence
│ │ │ preview, tools │ → receive a reviewable result
├───────────────┴───────────────────────────────────┴─────────────────┤
│ Persistent composer │
└─────────────────────────────────────────────────────────────────────┘
``` ```
The layout collapses gracefully on smaller screens. The UI MUST optimize **confidence per minute of human attention**, not display every agent action.
### Header ## 2. Information architecture
Global orientation/light controls: current organization switcher; current project or portfolio scope; search/command trigger; attention count; activity indicator; user menu. Do **not** fill it with model controls, agent selectors, or environment settings.
### Left work index
This is operational navigation, not chat history: **Now, Attention, Discovery, Active work, Waiting, Review, Monitoring, Recently completed, Goals, Projects, Knowledge, Activity**. Show compact counts/status indicators. A project section may show name, health, active-unit count, attention count, current goal/release.
### Primary workspace
Show portfolio/project overview, project overview, work-card feed, expanded work unit, global conversation moments, candidate review, attention workflow, results/learning.
### Context panel
Change with the selected object; show only when relevant (otherwise hide/collapse). Possible content: evidence, sources, files, PR, code changes, preview, test results, research, agent activity, decisions, dependencies, analytics, customer feedback, version history.
### Persistent composer
Available everywhere; scope is visibly lightweight:
```text ```text
Global workspace Project
Working in: Fix Safari OAuth callback failures ├── Conversation
├── Work cards
├── Attention
├── Artifacts/search
└── Project knowledge/settings
``` ```
Accept text, files, images, voice, links, source/work-unit/project mentions, and natural-language commands. Users never select a model, agent, or thinking mode. v0 may render Conversation and Work in one workspace with a side/detail panel.
## 5. Primary screens ## 3. Project conversation
### 5.1 Command Center (default) One continuous project conversation, not session/thread-centric navigation.
An operational briefing—not a wall of metrics—answering portfolio-level orientation, autonomous progress, meaningful changes, and required actions: Message types:
- **Today:** highest-value active work; upcoming deadlines; work likely to block; recently unblocked work. - user;
- **Needs attention:** approvals, decisions, questions, reviews, risks. - Zopu response;
- **Autonomous progress:** work advanced since last visit; new artifacts; completed verification; follow-up candidates. - Signal notice;
- **Project health:** current goal, active count, blocker count, recent result, risk/health signal per relevant project. - Work created/updated;
- **Recent outcomes:** completed work, measured effects, captured learnings. - human decision;
- important artifact/result.
### 5.2 Discovery Inbox Conversation actions:
Separate raw information from proposed interpretation. Views: new signals; candidate work; possible duplicates; suggested links to existing work; low-confidence observations. Actions: **Accept, Edit, Attach to existing work, Merge, Defer, Dismiss, Save as knowledge, Ask why**. Batch obvious duplicates/low-value signals when useful; important candidate creation remains inspectable. - send message;
- attach context;
- reference Work;
- create/modify priority;
- answer a question;
- ask for status/explanation.
### 5.3 Project Overview When a message creates Work, render a compact inline link/card. Preserve a link from Work back to exact source message.
One product/application context: project identity/health; current goals; active work; waiting/blocked work; recent releases/outcomes; connected sources; important context documents; team/responsibility; autonomous activity. It answers: what is the project trying to achieve, what is moving/blocked, what changed, and what requires the user? ## 4. Work card
### 5.4 Work Unit Workspace ### Collapsed
Expanded selected card; structure: Display only decision-relevant information:
| Section | Contents |
| --- | --- |
| Work header | Outcome title; organization/project; type; phase; status; priority; owner/responsible party; last meaningful change; autonomy state. |
| Objective | Desired outcome; why now; linked goal; success criteria; scope/non-goals. |
| Current understanding | Concise generated summary split into **Known, Inferred, Uncertain, Conflicting**. |
| Plan | Current approach; steps; completed actions; next action; alternative path when relevant. |
| Blockers/dependencies | For each: what is blocked, why, who/what resolves it, impact of waiting, requested human action. |
| Activity | Compressed chronology of user instructions, system/agent progress, state changes, decisions, artifacts, external actions, verification. |
| Artifacts | Previews/links for PRs, commits, documents, screenshots, previews, test reports, designs, research. |
| Results | After publication/completion: expected and actual result, measurement window, evidence, unexpected effects, follow-up recommendation. |
| Contextual composer | Fixed at bottom and clearly scoped to this unit. |
### 5.5 Attention Queue
Central human-in-the-loop surface; categories: **Decisions, Approvals, Questions, Blockers, Reviews, Access requests, Risk alerts**. Every item answers what/why is needed; recommendation and supporting evidence; what follows approval; consequence of no action; urgency; reversibility. Enable quick review without opening the full workspace unless deeper context is needed.
### 5.6 Activity Stream
Show meaningful autonomous events: work discovered/updated; run started; plan prepared; code changed; verification completed; artifact created; blocker detected; approval requested; work completed; result measured. Do not fill it with every tool call; events can expand for technical detail/provenance.
### 5.7 Results and Learning
A feedback system, not an archive. Views: recently completed; monitoring; successful outcomes; unexpected regressions; follow-up opportunities; reusable learnings. Each result links to original objective and evidence.
### 5.8 Knowledge
Expose durable organization/project understanding across **Product, Business, Customers, Design, Technical context, Decisions, Known constraints, Reusable learnings**. Mark each item **Canonical, Inferred, Pending review, Outdated, or Conflicting**.
## 6. Card system
A card is a visual projection of a durable product object. The same work unit may appear as a compact/expanded card, attention item, embedded conversation object, timeline event, or result card; never create separate task systems per surface.
| Type | Purpose and collapsed anatomy | Actions |
| --- | --- | --- |
| **Signal** | Newly detected information: source icon/name; observation; project/organization; time; suggested relationship; confidence. | Create work; attach to existing work; save as knowledge; dismiss. |
| **Candidate** | Proposed durable unit: proposed outcome; why it matters; project; linked goal; supporting-source count; confidence; duplicate warning when relevant. | Accept; edit; merge; defer; dismiss; ask why. |
| **Active work** | Operational state. Must answer what it is, why it matters, current state, blocker, and next step. Anatomy below. | Use status-appropriate actions. |
| **Waiting/blocked** | Visually distinct without alarmism: exact blocker; responsible party; time waiting; impact; next resolution action. | Review/resolve requested action. |
| **Approval** | Safe bounded decision: exact action; why; recommendation; alternatives; risk; impact; evidence; reversibility. | Approve; modify; reject; ask for more evidence; delegate. |
| **Review** | Evaluate artifact/completed run: what changed; acceptance criteria; verification summary; open concerns; preview/diff access; recommended decision. | Accept; request changes; continue discussion; compare alternative; stop work. |
| **Result** | Connect completed work to outcome: intended outcome; shipped work; expected impact; actual result; measurement period; unexpected effects; follow-up. | Accept result; continue monitoring; create follow-up; reopen; roll back where applicable. |
| **Parent initiative** | Larger outcome composed of child units: initiative outcome; child progress; critical path; blockers; next milestone; active/completed child counts. Do not duplicate every child detail. | Use child/initiative actions as applicable. |
### Card examples/anatomy
```text ```text
Support · Acme Corp · 18m ago Title
Three users could not finish OAuth setup in Safari. phase/status
Possible match: Fix Safari callback failures · Confidence: High latest meaningful update
[Attach] [Create separate work] [Dismiss] blocker/question
next action
slice progress
artifact count
``` ```
Example:
```text ```text
[Type] [Organization / Project] [Status] Add build-aware health endpoint
Fix Safari OAuth callback failures Verifying slice 2 of 3
Supports: Improve onboarding reliability · 42% complete HTTP behavior passed; design check pending
Blocked: Need production Safari logs Next: complete verifier review
Latest: Agent reproduced the issue · 18m ago 2 signals · 4 artifacts
Next: Approve temporary logging deployment
3 artifacts · 5 sources · 2 runs
``` ```
Avoid raw tokens, wakeups, grep calls, and internal actor noise.
### Expanded
Use six sections:
```text ```text
Waiting · Approval required 1. Outcome
Fix Safari OAuth callback failures 2. Design
Need: Approve 30-minute diagnostic logging 3. Build
Impact: Investigation cannot continue without production evidence 4. Evidence
[Review request] 5. Delivery
6. Result
``` ```
#### Outcome
- objective/user impact;
- source Signals;
- scope/non-goals;
- acceptance criteria;
- risk;
- assumptions/questions;
- definition approval.
#### Design
- impact map;
- architecture/sequence;
- file-tree delta;
- call-flow delta;
- key interfaces/invariants;
- vertical slices;
- design approval/revisions.
#### Build
- slice graph/progress;
- current attempt;
- meaningful activity;
- questions/decisions;
- retry/replan history;
- contextual composer.
#### Evidence
- verification checklist;
- command/test results;
- browser/API results;
- screenshots/video;
- design-conformance report;
- maintainability/security evidence;
- exact candidate SHA/environment.
#### Delivery
- commit/branch/PR;
- review package;
- preview/staging;
- merge/release approvals;
- rollout/rollback state.
#### Result
- expected vs actual outcome;
- post-release health;
- final classification;
- follow-up Signals;
- learning proposals.
## 5. Phase language
Use user-meaningful phases:
```text ```text
Approval required — Merge PR #184 Defining outcome
Why: All automated verification passed; no blocking review issues remain. Awaiting definition approval
Impact: Changes OAuth callback validation; no database migration. Designing
Evidence: 14 unit tests; 6 browser tests; preview reviewed; automated code review attached. Awaiting design approval
[Review changes] [Request changes] [Merge] Ready
Building slice N of M
Verifying slice N
Slice review required
Integrating
Ready for merge
Releasing
Observing result
Completed
Blocked
Needs input
Failed
Cancelled
``` ```
## 7. Card expansion Do not expose internal state-machine names unless in diagnostics.
Selecting a work card expands it in place in the primary workspace. On desktop, only one primary card is expanded; surrounding feed remains visible where practical; right panel follows selected section/artifact; composer scope becomes that unit; collapse retains card position; deep links open directly to a unit. Expansion must feel like entering a workspace, not a disconnected detail page. Mobile may use a full-screen sheet/route while preserving the model. ## 6. Attention objects
## 8. Global/contextual chat A question/approval is a structured card:
**Global mode** coordinates work across active organization/portfolio and may embed work cards/attention items. Example prompts: “What needs my attention today?”; “Create work for the repeated OAuth complaints.”; “Compare onboarding issues across my projects.”; “What is blocked for the release?”; “Move the pricing experiment behind the reliability work.”; “Summarize what progressed overnight.”
**Context mode** focuses one work unit: “Add Safari 17 to the acceptance criteria.”; “Explain the current hypothesis.”; “Show me the failing test.”; “Try a solution without changing the data model.”; “Prepare a smaller pull request.”; “Ask the customer for the missing information.” Header shows scope and easy return to global.
When work is described, the system may update an existing unit, create a candidate, create accepted work when intent is explicit and policy permits, ask a clarifying question, or save knowledge. Briefly state the outcome:
```text ```text
Created candidate work: Improve OAuth diagnostics Decision title
Linked to: Restore OAuth reliability · Sources attached: 3 exact question
why it matters
recommendation
alternatives
consequences
affected Work/slice
primary action
reply action
``` ```
Mentions insert structured references (without copying IDs/links) to a work unit, project, goal, artifact, source, or person. Example:
## 9. Status design
Use these user-facing statuses and explicit primary actions:
| Status | Meaning | Primary actions |
| --- | --- | --- |
| **Proposed** | Suggested, not started. | Accept; edit; merge; dismiss. |
| **Clarifying** | Outcome/requirements incomplete. | Answer question; add context; accept assumption. |
| **Ready** | Defined enough to begin. | Start; review plan; change priority. |
| **Active** | Progressing. | Inspect; steer; pause; add context. |
| **Waiting** | Depends on person, dependency, approval, or external event. | Resolve blocker; grant access; make decision; continue waiting. |
| **Review** | Artifact/result needs evaluation. | Approve; request changes; continue discussion. |
| **Monitoring** | Shipped; outcome is being watched. | Inspect result; extend monitoring; create follow-up; roll back. |
| **Done** | Completed or intentionally stopped. | View result; reopen; create follow-up; archive. |
Modifiers: **Blocked, Approval required, Waiting on user, Waiting on dependency, Running autonomously, Scheduled, At risk**. Always pair color with text and iconography.
## 10. Activity language
Activity is meaningful product language, not raw agent noise.
- **Prefer:** Investigated authentication flow; identified likely state-validation bug; added regression tests; verification passed; PR created; waiting for merge approval.
- **Hide from primary UI (technical drawer only):** called grep; ran shell command; read 34 files; model generated 2,600 tokens; actor woke.
Group related events into sessions:
```text ```text
Implementation run · 24 minutes Should /health be public?
• Inspected callback handler • Added state validation
• Added regression tests • 14 tests passed Recommendation
• Created PR #184 Expose basic status publicly; protect detailed diagnostics.
Why
Public uptime checks need access, but build metadata may reveal deployment data.
[Use recommendation] [Reply]
``` ```
## 11. Provenance Answering MUST resume the same Work. Never create a disconnected chat thread.
Four progressively disclosed levels: Attention inbox ordering:
1. **Source chips (on cards):** Git, Support, Analytics, Conversation, System inference, Human decision. 1. production/security blockers;
2. **Evidence panel:** sources supporting current understanding/recommendation. 2. decisions blocking active Work;
3. **Raw source:** original message, issue, log, file, or event. 3. review/approval requests;
4. **Derivation detail (high-impact claims):** conclusion; evidence; assumptions; confidence; conflicting evidence; last updated; producing agent/process. 4. informational updates.
Labels: **Verified, Reported, Inferred, Assumed, Conflicting, Outdated**. Do not force derivation detail during normal operation. ## 7. Definition and design review
## 12. Organization/project/portfolio scope Use diffable structured documents, not long prose blobs.
Users may manage several organizations/projects. The active organization is always visible; switching organization changes the full scope; never blend private context across organizations. Portfolio views may aggregate counts/summaries only with explicit labels. Composer indicates portfolio, organization, project, or work-unit scope. ### Work Definition editor
Editable fields:
- problem;
- outcome;
- scope/non-goals;
- acceptance criteria;
- assumptions;
- questions;
- risk.
Actions:
```text ```text
Portfolio Approve | Request revision | Edit directly
├── Company A
│ ├── Product Alpha
│ └── Internal Platform
└── Client B
└── Customer Portal
``` ```
Do not require deep-tree navigation for every action: provide search, recent projects, and scope-aware commands. ### Design Packet review
## 13. Project onboarding Show:
Onboarding prepares a capable working environment, not a long settings form. - one architecture diagram;
- expected file-tree changes;
- expected call flow;
- key types/contracts;
- slices;
- risks/trade-offs.
- **Entry:** start from template; connect existing repository; import repository; create empty project. Highlight changes between versions. Approval records actor/user, version, timestamp, and optional comment.
- **Initial templates:** React, Hono, Express, full-stack web application, API service, Expo application, monorepo. Each card states inclusions, fit, expected setup time, and included verification.
- **Context setup:** concise sections for Product, Business, Design, Technical conventions, Agent instructions. System may draft from repository/conversation; user reviews/confirms. Allow progressive completion; clearly mark missing context; do not require complete documents before starting.
- **Readiness:** Repository ready; Dependencies prepared; Context confirmed; Verification passed; Preview available; Agent ready. Describe infrastructure in plain operational language, not raw logs.
## 14. Artifacts ## 8. Slice presentation
Artifacts are first-class in the work unit. Present as PR summary, code diff, preview frame, test report, screenshot gallery, research document, design proposal, generated file, or release note. Each shows **name, type, producer, time, related run, verification state, review state, source/destination**. Open in the right panel or focused viewer without breaking work-unit context. Each slice shows:
## 15. Human actions
Use explicit actions, never vague “continue”: **Approve plan; answer question; grant temporary access; review preview; review changes; request revision; approve branch push; approve PR; approve merge; approve deployment; send message; pause autonomous work; cancel run; reopen work.** Every sensitive action shows scope and consequence:
```text ```text
Deploy preview objective
Project: Zopu Web · Branch: work/W-103/team-invitations observable behavior
Environment: Temporary preview · External impact: None planned code changes
Expected duration: 20 minutes required checks
[Deploy preview] dependencies
status
attempt count
artifacts
review requirement
``` ```
## 16. Autonomy controls A timeline is useful, but the primary representation is a dependency-aware list.
Do not expose a complex agent-control panel by default. States: **Manual; Ask before actions; Autonomous within policy; Paused**. Project/organization policy can set defaults. Example unit indicators: Slice statuses:
```text ```text
Autonomous within policy · No action required Pending | Ready | Running | Verifying | Review | Passed | Failed | Blocked
Paused after repeated verification failure · Review required
``` ```
Advanced settings may expose policy detail; everyday use stays simple. ## 9. Activity design
## 17. Empty, loading, failure, conflict Normalize events into concise statements:
| State | Required behavior | Good:
| --- | --- |
| Empty workspace | Never show a blank dashboard; offer Create project, Connect repository, Describe what you are working on, Import existing issues, Start from a template. |
| No active work | Show recent outcomes and suggested candidates; never invent busywork. |
| Run provisioning | Use “Preparing workspace”, “Loading project context”, “Starting implementation”; expose infrastructure terms only when delayed/failed. |
| Failure | Explain what failed; preserved work; retry safety; recommendation; whether human action is required. Example: “Verification failed. The implementation is saved, but 2 browser tests are failing. The system recommends another repair attempt.” Actions: Review failures / Retry repair / Stop work. |
| Conflicting evidence | Show conflict, never silently choose: “Support reports OAuth failures increased; Analytics currently shows no significant change.” Actions: Inspect sources / Continue monitoring / Start investigation. |
## 18. Responsive behavior ```text
Implemented build metadata provider
Focused tests passed
Verifier found response schema mismatch
Repair attempt started with failure evidence
```
| View | Layout/behavior | Bad:
| --- | --- |
| Desktop | Three columns; one expanded unit; right contextual panel; persistent bottom composer. |
| Tablet | Collapsible left index; primary workspace; right panel as overlay or resizable drawer. |
| Mobile | Bottom navigation **Now, Attention, Work, Projects**; vertical card feed; expanded work full-screen route/sheet; composer fixed above navigation; context content in tabs/sheets. Prioritize supervision, review, approval over full code inspection. |
## 19. Visual language ```text
Tool call: bash
Read 42 files
Generated 7,932 tokens
Actor wake event
```
Direction: **calm, precise, premium, spacious, highly legible, modern without looking experimental**. Use warm white or restrained dark backgrounds; soft neutral surfaces; thin separators; large rounded corners; subtle depth; strong type hierarchy; restrained accent; selective pastel category/phase states; smooth expansion/scope transitions. Provide an expandable diagnostics/log panel for experts.
Avoid dense enterprise dashboards; excessive gradients; permanent borders around every item; bright status colors everywhere; model/agent-control clutter; chat bubbles dominating; Kanban as default mental model; gamification. It should feel like a focused operating environment, not AI widgets. ## 10. Verification UX
## 20. Motion Checklist rows contain:
Motion reinforces state/continuity: card→workspace expansion; composer-scope change; candidate→accepted work; approval resolving/work resuming; new artifact; blocker clearing. Avoid decorative distraction; the system feels alive because work meaningfully changes, not because it constantly animates. ```text
check name
status
scope
candidate revision
evidence link
duration
failure summary
```
## 21. Accessibility Statuses:
Require full keyboard navigation; clear focus states; screen-reader labels for statuses/actions; status never color-only; sufficient light/dark contrast; reduced-motion support; predictable card expansion; confirmations for destructive/external effects; human-readable relative timestamps with exact-time access. ```text
Queued | Running | Passed | Failed | Skipped | Inconclusive
```
## 22. MVP Failures should include:
### Screens - exact assertion/exit code;
- relevant output;
- expected vs actual;
- repair action;
- retry count.
1. **Active Work:** work-card feed; project/status filters; expanded work unit. Design-conformance findings are labeled:
2. **Discovery Inbox:** signals; candidate work; merge/attach.
3. **Attention Queue:** decisions; approvals; questions; reviews.
4. **Project Overview:** goals; active work; project context; artifacts/recent outcomes.
All four persist: header; work index; global/contextual composer; realtime activity. ```text
Conforms | Intentional deviation | Concern | Unknown
```
### Cards Metrics never masquerade as truth; human review may accept justified deviations.
Signal; Candidate; Active work; Waiting/blocker; Approval; Review; Result. Do not create one card type per technical event; use sections/modifiers in the core system. ## 11. Review package
## 23. Success criteria The delivery screen should narrate the change in review order:
Within seconds, users can answer: **What should I focus on? What progressed without me? What is blocked? What requires my decision? Why is this happening? What evidence supports current understanding? What happens next? What changed in the project? What result did completed work produce?** They can start, steer, review, or stop work without understanding internal agent/infrastructure architecture. 1. original intent;
2. accepted behavior;
3. architecture/program-design summary;
4. slice-by-slice implementation;
5. screenshots/video/API examples;
6. important diffs;
7. verification evidence;
8. deviations and risks;
9. exact commit and PR actions.
## 24. Shipping checklist Primary actions:
- Primary object is a work unit, not a chat session. ```text
- Organization/project scope is clear. Open PR | Approve | Request changes | Answer blocker
- Next human action is obvious. ```
- Machine actions and human decisions are separated.
- Provenance is available.
- Uncertainty is represented honestly.
- Detail is progressively disclosed.
- Technical internals are hidden unless useful.
- Composer preserves continuity.
- Experience reduces coordination rather than creating more.
## 25. Core experience statement Merge remains external/manual initially.
Zopu should feel like supervising a capable operating system, not operating a collection of agents: one workspace shows meaningful work; the user resolves the few things requiring judgment and continues conversation where needed; the system handles context, execution, progress tracking, and knowledge retention beneath a calm surface organized around persistent outcomes. ## 12. Result and learning UX
After delivery:
```text
Expected
Actual
Evidence
Classification
Follow-ups
```
Learning proposals are reviewable cards:
```text
Proposed update
reason/evidence
target document/Kit
diff
[Accept] [Edit] [Reject]
```
Never silently rewrite canonical knowledge.
## 13. Empty/loading/error states
### Empty project
```text
Describe what you want to accomplish.
Zopu will convert actionable intent into reviewable Work.
```
### Processing
Use phase-specific state:
```text
Extracting actionable intent…
Drafting Work Definition…
Preparing Design Packet…
```
### Failure
Always show:
- failed phase;
- durable status;
- retained evidence;
- recommended next action;
- retry/replan/cancel controls.
Never show an endless generic spinner.
## 14. Responsive behavior
Mobile:
- Conversation is primary.
- Work cards appear inline and in a drawer.
- Attention cards pin above composer.
- Expanded Work uses stacked sections.
- Diagnostics remain secondary.
Desktop:
- Conversation left/main.
- Work list/detail right or split panel.
- Attention queue accessible globally.
## 15. Accessibility and interaction rules
- All status meaning has text, not color alone.
- Streaming updates preserve focus.
- Approvals require explicit controls.
- Destructive actions show scope and consequence.
- Keyboard navigation covers cards, sections, actions.
- Long logs are virtualized/collapsed.
- Timestamps and actor identity are available in audit detail.
## 16. v0 screens
Only build:
```text
Project workspace
Work detail
Attention state inside Work
Verification/review package inside Work
```
Do not build a separate dashboard for every backend module.
## 17. Design acceptance
The experience is successful when a technical founder can:
1. state intent in chat;
2. see exact intent become a Work card;
3. approve a testable definition/design;
4. understand current progress in under a minute;
5. answer a blocker without finding an agent session;
6. inspect verification evidence;
7. reach the exact verified PR;
8. understand why Zopu considers the Work ready.

View File

@@ -1,379 +0,0 @@
# Zopu Dogfood v0 — Complete Loop
> Integration and operations document for the first end-to-end dogfood loop. Grounded in the merged code on `dogfood/v0`. Read alongside [docs/PRODUCT.md](./PRODUCT.md), [docs/DESIGN.md](./DESIGN.md), [docs/TECH.md](./TECH.md), and [docs/SMOKE.md](./SMOKE.md).
## 1. What this loop proves
A user sends a message in the web Work OS. Zopu (the global planning agent) extracts a Signal, routes it to a ProjectIssue, and the user starts work. The issue runs inside an isolated execution environment (Orb: Docker + AgentOS + OpenCode), produces a verified change on a work branch, opens a Gitea pull request, and reports the outcome back into durable state. A GLM planner/reviewer independently approves the result.
The loop touches every layer: control plane (Convex), agent service (Flue), execution plane (Orb runtime), model gateway, Git/Gitea, and the web UI.
## 2. Architecture summary
```
┌──────────────────────────────────────────────────────────────────┐
│ Web Work OS (apps/web) │
│ Conversation composer → Zopu agent → Signals → Work Units │
│ Start button → projectIssues.begin + Flue project-manager send │
└──────────────┬───────────────────────────────────────────────────┘
┌──────────▼──────────┐
│ Convex (control plane) │
│ projects, projectIssues, │
│ signals, events, artifacts│
└──────────┬──────────┘
┌──────────▼──────────────────────┐
│ Flue agent service (:3583) │
│ ┌─────────────────────────────┐ │
│ │ zopu agent │ │
│ │ (global conversation → │ │
│ │ Signal → issue routing) │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ project-manager agent │ │
│ │ (issue-scoped work, │ │
│ │ AgentOS sandbox, │ │
│ │ finalize_gitea_lifecycle) │ │
│ └─────────────────────────────┘ │
└──────────┬──────────────────────┘
┌──────────▼──────────────────────────────────────┐
│ Orb Runtime (Docker + AgentOS + OpenCode) │
│ OrbProjectManager orchestrates: │
│ createOrb → prepareRepository → openSession │
│ → sendTask (context pack) → model turn │
│ → WORK_COMPLETE / NEEDS_INPUT detection │
│ → Git lifecycle (commit, push, Gitea PR) │
│ Scripts: scripts/orb-proof.ts (3-stage proof) │
│ scripts/orb-project-run.ts (full issue) │
└──────────┬──────────────────────────────────────┘
┌──────────▼──────────┐ ┌───────────────────┐
│ Model gateway (CPA) │ │ Gitea (self-hosted)│
│ OpenAI-compatible │ │ PRs, branches │
│ minimax-m3 (worker) │ │ │
│ glm-5.2 (planner) │ │ │
└──────────────────────┘ └───────────────────┘
```
### Two execution paths
The codebase has two ways to run issue-scoped work, both merged and functional:
1. **Flue project-manager path** (the web start button). The web UI calls `projectIssues.begin` (Convex mutation) then sends a message to the `project-manager` Flue agent (`apps/web/src/hooks/use-project-workspace.ts`). The agent works inside an in-process AgentOS sandbox and calls the `finalize_gitea_lifecycle` action (`packages/agents/src/actions/finalize-gitea-lifecycle.ts`) to open a Gitea PR.
2. **Orb project-manager path** (Docker-isolated). The `OrbProjectManager` (`packages/agents/src/orb/orb-project-manager.ts`) orchestrates a Docker sandbox + AgentOS VM + OpenCode session per issue. It projects raw OrbEvents into durable `ProjectRunEvent`s, detects `WORK_COMPLETE:` / `NEEDS_INPUT:` markers, and drives the Git lifecycle via `git-adapter.ts`. The entry point is `scripts/orb-project-run.ts`.
The Orb path provides stronger isolation (full Docker container vs. in-process VM) and is the forward path for the dedicated server. The Flue path is the current local MacBook flow.
### Key files
| Component | File |
| --- | --- |
| Zopu agent (global routing) | `packages/agents/src/agents/zopu.ts` |
| Project-manager agent (Flue) | `packages/agents/src/agents/project-manager.ts` |
| Signal routing tools | `packages/agents/src/tools/signals.ts` |
| Orb runtime | `packages/agents/src/orb/runtime.ts` |
| Orb project-manager | `packages/agents/src/orb/orb-project-manager.ts` |
| Orb adapter | `packages/agents/src/orb/orb-adapter.ts` |
| Git adapter (Gitea lifecycle) | `packages/agents/src/orb/git-adapter.ts` |
| Docker sandbox | `packages/agents/src/orb/docker-sandbox.ts` |
| OpenCode config injection | `packages/agents/src/orb/opencode-config.ts` |
| Context pack assembly | `packages/agents/src/orb/context-pack.ts` |
| Event projection | `packages/agents/src/orb/project-events.ts` |
| finalize_gitea_lifecycle action | `packages/agents/src/actions/finalize-gitea-lifecycle.ts` |
| Smoke harness | `scripts/zopu-smoke.ts` |
| Orb proof fixture | `scripts/orb-proof.ts` |
| Orb issue driver | `scripts/orb-project-run.ts` |
| Web start hook | `apps/web/src/hooks/use-project-workspace.ts` |
| Convex projectIssues | `packages/backend/convex/projectIssues.ts` |
| Convex signals | `packages/backend/convex/signals.ts` |
| Daemon runtime | `apps/daemon/src/runtime.ts` |
## 3. Service startup order
Services must start in dependency order. The daemon owns the in-process RivetKit engine that the agent service depends on.
### Local MacBook development
```bash
# 1. Convex dev server (control plane + generated API)
bun run dev:server
# 2. Agent daemon (Effect daemon + RivetKit engine + AgentOS)
bun run dev:daemon
# 3. Flue agent service (loads zopu + project-manager agents on :3583)
bun run dev:agents
# 4. Web app (Vite on :5173)
bun run dev:web
# 5. (Optional) Docker Desktop — required for the Orb path
open -a Docker
```
### Dedicated server
See [deploy/zopu-runtime/README.md](../deploy/zopu-runtime/README.md). The systemd order is:
```bash
systemctl start zopu-daemon # starts RivetKit engine on :6420
sleep 3
systemctl start zopu-agent # starts Flue on :3583
```
The daemon must be active before the agent service because `registry.start()` boots the in-process RivetKit engine that the agent's `createClient(RIVET_ENDPOINT)` connects back to.
## 4. Required environment variables
All groups are documented in [deploy/zopu-runtime/.env.template](../deploy/zopu-runtime/.env.template). Parsed by `packages/env/src/agent.ts` (`parseAgentEnv`) and `packages/env/src/server.ts`.
### Convex (control plane)
| Variable | Required | Description |
| ----------------- | -------- | --------------------------------- |
| `CONVEX_URL` | Yes | Convex deployment URL |
| `CONVEX_SITE_URL` | Yes | Convex site URL (for actions) |
| `SITE_URL` | Yes | Web app origin (CORS / redirects) |
### Self-hosted Git / Gitea
| Variable | Required | Description |
| --- | --- | --- |
| `GITEA_URL` | For PR lifecycle | Gitea base URL (default: `https://git.openputer.com`) |
| `GITEA_TOKEN` | For PR lifecycle | Gitea API token for branch push + PR creation |
### Model gateway (CPA)
| Variable | Required | Description |
| --- | --- | --- |
| `AGENT_MODEL_PROVIDER` | Yes | Provider identifier (e.g. `cheaptricks`) |
| `AGENT_MODEL_NAME` | Yes | Model name (e.g. `glm-5.2`) |
| `AGENT_MODEL_API` | Yes | Must be `openai-completions` |
| `AGENT_MODEL_BASE_URL` | Yes | OpenAI-compatible `/v1` endpoint |
| `AGENT_MODEL_API_KEY` | Yes | Gateway API key |
| `AGENT_MODEL_CONTEXT_WINDOW` | Yes | Context window in tokens |
| `AGENT_MODEL_MAX_TOKENS` | Yes | Max output tokens |
The smoke worker uses `minimax-m3`; the planner/reviewer uses `glm-5.2`. Both must be in the CPA model catalog.
### AgentOS / RivetKit
| Variable | Required | Description |
| --- | --- | --- |
| `RIVET_ENDPOINT` | No | RivetKit engine endpoint (default: `http://localhost:6420`) |
`registry.start()` boots an in-process engine (envoy mode) backed by a native Rust sidecar. No separate Rivet Engine process is required for single-node operation.
### Zopu agent service (Flue)
| Variable | Required | Description |
| --------------- | -------- | --------------------------------------- |
| `FLUE_DB_TOKEN` | Yes | Flue persistence adapter token |
| `PORT` | Yes | Flue HTTP listen port (default: `3583`) |
### Daemon identity
| Variable | Required | Description |
| --- | --- | --- |
| `DAEMON_ID` | Yes | Unique daemon identifier |
| `DAEMON_NAME` | Yes | Human-readable name |
| `DAEMON_VERSION` | Yes | Daemon version string |
| `DAEMON_HEARTBEAT_MS` | Yes | Heartbeat interval (default: `15000`) |
| `DAEMON_COMMAND_LEASE_MS` | Yes | Command lease duration (default: `60000`) |
### Docker sandbox (Orb path)
| Variable | Required | Description |
| --- | --- | --- |
| `ORB_DOCKER_IMAGE` | No | Docker image override (default: `rivetdev/sandbox-agent:0.5.0-rc.2-full`) |
| `ORB_DOCKER_WORKSPACE` | No | Host workspace root (default: `/tmp/orb-workspaces`) |
Docker socket access is via group membership on the dedicated server.
### Service authentication
| Variable | Required | Description |
| ------------- | --------- | ------------------------------- |
| `AUTH_SECRET` | If needed | Better Auth / Convex JWT secret |
## 5. Local MacBook development flow
```bash
# Clone and install
git clone <repo> zopu && cd zopu
git checkout dogfood/v0
bun install
# Start services in order (see startup order above)
bun run dev:server # Convex
bun run dev:daemon # daemon + RivetKit engine
bun run dev:agents # Flue agents
bun run dev:web # web UI
# Verify the loop is wired
bun run smoke:zopu
# Run the Orb proof (requires Docker + gateway)
ORB_PROOF=1 \
ORB_GATEWAY_API_KEY=... \
ORB_GATEWAY_BASE_URL=https://ai.example.com/v1 \
ORB_GATEWAY_MODEL=glm-5.2 \
ORB_GATEWAY_PROVIDER=cheaptricks \
bun run orb:proof
# Drive one issue through the Orb project-manager (requires Docker + gateway)
ORB_RUN=1 \
ORB_GATEWAY_API_KEY=... \
ORB_GATEWAY_BASE_URL=https://ai.example.com/v1 \
ORB_GATEWAY_MODEL=glm-5.2 \
ORB_GATEWAY_PROVIDER=cheaptricks \
bun run orb:run
```
## 6. Dedicated-server runtime flow
The dedicated server runs only the execution plane. Convex is already deployed as the control plane.
```bash
# SSH into the fresh Debian 12 server as root
export ZOPU_REPO_URL="ssh://git@git.openputer.com:2222/puter/zopu-code.git"
export ZOPU_REPO_BRANCH="dogfood/v0"
bash deploy/zopu-runtime/bootstrap.sh
# Edit .env with real values
nano /opt/zopu/.env
# Start services
systemctl start zopu-daemon
sleep 3
systemctl start zopu-agent
# Enable timers
systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer
# Verify
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
```
See [deploy/zopu-runtime/README.md](../deploy/zopu-runtime/README.md) for update, rollback, Docker cleanup, disk monitoring, firewall, and Tailscale details.
## 7. Demo procedure
Prerequisites: all services running, a disposable test project with a connected repository source, and the issue-scoped artifact set.
1. Open the web app (`http://localhost:5173` or the Tailscale hostname).
2. Select the test project in the Work OS.
3. Type an actionable message in the composer (e.g. "Add a tiny status indicator to the dashboard"). Zopu creates a Signal and routes it to a ProjectIssue.
4. The Work Unit card appears. Click **Start work**.
5. The web UI calls `projectIssues.begin` then sends the issue to the `project-manager` Flue agent. The agent works inside its sandbox.
6. Watch the Work Unit timeline for `run.session_opened`, `run.agent_message`, and `run.command_executed` events.
7. When the agent emits `WORK_COMPLETE:`, the Git lifecycle runs (commit, push, Gitea PR).
8. The Work Unit card shows the PR link. Click **Review changes** to open the Gitea PR.
9. Merge remains a manual action.
For the Orb Docker path, replace step 4-5 with:
```bash
ORB_RUN=1 \
ORB_GATEWAY_API_KEY=... \
ORB_GATEWAY_BASE_URL=... \
ORB_GATEWAY_MODEL=glm-5.2 \
ORB_GATEWAY_PROVIDER=cheaptricks \
GITEA_URL=... GITEA_TOKEN=... \
ORB_RUN_REPOSITORY_URL=https://git.example.com/owner/repo.git \
ORB_RUN_REPOSITORY_PATH=owner/repo \
bun run orb:run
```
## 8. Health checks
### Local
```bash
bun run smoke:zopu # full preflight (all layers)
curl -s http://localhost:5173/ # web
curl -s http://localhost:3583/ # Flue (no health endpoint by design)
```
### Dedicated server
```bash
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
# Probes: systemd units active, RivetKit :6420 TCP, Flue :3583 TCP, docker info
```
## 9. Logs
### Local
Flue agent logs go to stdout in the `bun run dev:agents` terminal. Daemon logs go to the `bun run dev:daemon` terminal.
### Dedicated server
```bash
journalctl -u zopu-daemon -f # daemon (live)
journalctl -u zopu-agent -f # agent (live)
journalctl -u zopu-daemon -n 100 # last 100 lines
journalctl -u zopu-health.service -n 50
journalctl -t zopu-daemon -t zopu-agent --since "1 hour ago"
```
## 10. Failure recovery
| Symptom | Diagnosis | Recovery |
| --- | --- | --- |
| `ZOPU_SMOKE_CONTRACT_BLOCKED` with `convex` failed | Convex dev server not running | `bun run dev:server` |
| `ZOPU_SMOKE_CONTRACT_BLOCKED` with `flue` failed | Flue agent service not running | `bun run dev:agents` |
| `ZOPU_SMOKE_CONTRACT_BLOCKED` with `docker-daemon` failed | Docker not running | Start Docker Desktop / `systemctl start docker` |
| `ZOPU_SMOKE_CONTRACT_BLOCKED` with `gitea-creds-valid` failed | Missing or invalid `GITEA_TOKEN` | Set `GITEA_URL` + `GITEA_TOKEN` in env |
| `ORB_PROOF_BLOCKED` at stage 2 | AgentOS VM creation failed | Check Docker daemon + `rivetdev/sandbox-agent` image |
| `ORB_PROOF_BLOCKED` at stage 3 | Model gateway unreachable | Check `ORB_GATEWAY_*` env and gateway connectivity |
| Work Unit stuck in `needs-input` | Agent emitted `NEEDS_INPUT:` | Resolve the question and send a follow-up message |
| PR not created after `WORK_COMPLETE` | Gitea creds or repo path missing | Verify `GITEA_URL`, `GITEA_TOKEN`, repository path |
| Daemon offline in Convex | Daemon process crashed | `systemctl restart zopu-daemon` |
## 11. Cleanup
### Local Orb artifacts
```bash
docker container prune -f --filter "label=orb"
rm -rf /tmp/orb-* /tmp/orb-proof-*
```
### Dedicated server
```bash
/opt/zopu/deploy/zopu-runtime/scripts/docker-cleanup.sh # prune stopped containers/images
```
Named volumes and running containers are never removed.
## 12. Known limitations
1. **Two parallel execution paths.** The Flue `project-manager` agent (in-process AgentOS sandbox) and the `OrbProjectManager` (Docker + AgentOS + OpenCode) both work but are not yet unified behind a single dispatcher. The web start button uses the Flue path; the Orb path is driven by `scripts/orb-project-run.ts`. Unifying these behind the Work Unit start button is a forward milestone.
2. **No auto-merge.** The Git lifecycle opens a pull request and stops. Merge is always a manual human action, by design.
3. **Docker not wired on the dedicated server.** Docker Engine is installed and the `zopu` user is in the `docker` group, but the daemon currently uses the in-process AgentOS VM sandbox. The Orb Docker path is proven locally via `scripts/orb-proof.ts` but not yet the default on the server.
4. **OpenCode ACP gateway wiring.** The model gateway works via `OPENCODE_CONFIG_CONTENT` injected through the package manifest env + OpenAI seed model remapping (see `packages/agents/src/orb/opencode-config.ts` and `runtime.ts`). The three-stage orb proof passes. This is a known working boundary, not a general-purpose provider configuration.
5. **Pre-existing check failures.** The root `bun run check` has a pre-existing unrelated failure: formatting under `repos/effect` and dual Hono patch versions (4.12.30 vs 4.12.31). These are not related to the dogfood loop and should not be fixed in this lane.
6. **Single-node only.** The dedicated server runs one node with an in-process RivetKit engine. Multi-node coordination is out of scope.
7. **Web app not deployed on the server.** The server runs the execution plane only; the web frontend runs locally or is deployed separately.
8. **No production generated-app hosting.** Only the execution plane runs on the dedicated server.
## 13. Next three incremental milestones
1. **Unify the execution dispatcher.** Route the web Work Unit start button through a single dispatcher that selects the Orb Docker path (when Docker is available) or the Flue in-process path (fallback). This eliminates the two-parallel-paths limitation and makes the Orb path the default on the dedicated server. Touch point: `apps/web/src/hooks/use-project-workspace.ts` start action + a new Convex action that invokes `OrbProjectManager`.
2. **Stream Orb events into Convex ProjectEvents.** The `OrbProjectManager.onProjectEvent` callback currently writes to an in-memory array. Wire it to persist `ProjectRunEvent`s into the Convex `projectEvents` table so the web Work Unit timeline shows live Orb execution progress. Touch point: `packages/agents/src/orb/orb-project-manager.ts` + a new Convex mutation mirroring `agentWorkspace.recordGiteaLifecycle`.
3. **Durable run resume after daemon restart.** `OrbProjectManager` holds active runs in memory. Add a Convex-backed run ledger so an active Orb run can resume after a daemon restart instead of being lost. This moves the run-state machine from in-memory to durable, matching the `OrbState` / `RunState` transitions in `packages/agents/src/orb/domain.ts`.

View File

@@ -1,228 +1,422 @@
# Zopu Work OS — Product # Zopu Work OS — Product Specification
> Working spec · product/design/ops/research/agent teams · concepts, behavior, boundaries, value. Excludes implementation, infra, tech choices, low-level UI. > **Related:** `dev-loop.md` defines delivery; `slices.md` defines product increments; `evaluation.md` defines quality measurement.
## 1. Summary > **Status:** Working source of truth
> **Audience:** product, design, engineering, agents
> **Normative words:** **MUST** = required, **SHOULD** = default, **MAY** = optional
> **Product boundary:** this file defines *what and why*, not implementation details.
Zopu Work OS is a persistent OS for technical founders, product engineers, and small teams managing the full lifecycle of building and growing software across products, companies, repos, customers, and functions. It replaces fragmented chats, trackers, docs, and manually coordinated AI agents with one coherent system organized around **units of work**. ## 1. Thesis
One continuous workspace discovers work, builds understanding, coordinates execution, requests human judgment when needed, tracks outcomes, and retains knowledge. Not a better chat app or just another PM tool — it continuously converts company signals into structured, persistent, executable work. Zopu is a **completion system for durable units of work**, not a chat wrapper and not a coding-agent launcher.
**Promise:** One workspace that understands the company, turns signals into work, progresses work autonomously, and brings the human in only when direction, judgment, permission, or taste is required. The product converts conversation and external events into verified outcomes:
## 2. Customer ```text
Talk/events → Signals → Work → Definition → Design → Slices
→ Resolution → Verification → Delivery → Observation → Learning
```
**Ideal:** technical founder/small team that operates 1+ software products across several companies/clients/repos; moves between engineering, product, support, sales, marketing, ops, strategy; uses many tools without a unified model; uses AI coding tools but finds session/thread fragmentation; needs more execution capacity without hiring per function; needs context continuity, reliable delegation, visibility into autonomous work. The core promise:
**Secondary:** product engineers (several initiatives), fractional CTOs, agencies/studios, solo founders, small product/eng teams, multi-internal-product operators. > Given an accepted unit of work, Zopu keeps advancing it until a verified outcome exists or a precise terminal blocker is recorded.
Technical-first because code is a clear execution domain (measurable artifacts, established workflows, strong familiarity). Broader direction: research, content, marketing, support, product ops, sales enablement, other knowledge/creative work. Agents may propose, plan, implement, review, and test. **The system decides completion from evidence.**
## 3. Problem — work is fragmented three ways ## 2. Initial customer and scope
| Fragmentation | Detail | ### ICP v0
| --- | --- |
| **Across tools** | One problem spans support→analytics→tracker→browser→repo→PR→release notes→customer comms. Each tool stores a partial view; none owns the lifecycle. |
| **Across AI sessions** | Chat/thread-organized work forces repeated decisions: which conversation, what context to restate, which model/agent, where to store output, how it relates to existing work, what changed after shipping. Conversation becomes structure though conversations are temporary and work is persistent. |
| **Founders as coordination** | Small-team founders bridge every system/function: remember decisions, connect feedback to work, check blockers, move work between tools, review AI output, decide next steps. The founder becomes the human integration bus. |
Zopu removes this burden by maintaining a persistent model of work, context, state, evidence, execution, and outcomes. Technical founders and small engineering teams working in one Git repository.
## 4. Thesis ### Supported v0 workflow
- **Conversation is the interface, not the structure.** The durable structure is the **work unit**. Changing tasks must not require a new chat; the persistent conversation's scope moves between whole workspace and a specific work unit. ```text
- **Work = outcomes, not activity.** Represent: _fix Safari OAuth failures; reduce onboarding abandonment; launch team invitations; resolve a billing escalation; rewrite the pricing page; validate a direction._ Do not promote every message/tool-call/agent action to a top-level task. Project chat
- **The product participates in work** — not just store/display: discover, understand, plan, execute, verify, publish, measure. → actionable Signal
- **Autonomy reduces attention, not adds noise.** Successful when it makes correct progress, informs at the right level, asks only when necessary. → approved Work Definition
- **Knowledge compounds.** Every decision, result, failure, signal, artifact improves understanding and accelerates/future-proofs future work. → lightweight Design Packet
→ vertical slices
→ isolated coding run
→ independent verification
→ branch + pull request
→ human merge
→ result assessment
```
## 5. Core objects ### v0 constraints
Use a small set of durable concepts that stays stable across domains. - One user/organization/project/repository path first.
- Web interface only.
- One implementation harness first.
- One sandbox provider first.
- Manual merge.
- No autonomous production mutation.
- No arbitrary generated tools receiving immediate execution authority.
- No generic support for every founder workflow yet.
### Organization The domain must remain broader than Git, but the implementation should prove quality on software delivery first.
Hard business/permission/knowledge boundary. Represents a company, client, startup, internal BU, or personal portfolio. Owns its users, products, policies, integrations, knowledge, work, history. **Information never moves between organizations implicitly.** ## 3. Product surfaces
### Project / product The primary interface remains intentionally small:
Durable operating context within an org: software product, repo-backed app, client engagement, internal platform, website/service, focused initiative. Contains goals, product knowledge, conventions, connected sources, relevant people, active work. Reduces context ambiguity without forcing manual folder structures. ```text
1. Continuous project conversation
2. Work cards
3. Human attention queue
```
### Goal ### Continuous conversation
What the org/project is trying to achieve (e.g. reduce onboarding failure, reach a reliability target, launch enterprise support, improve trial→paid, close first ten enterprise customers). Distinguishes valuable work from merely possible. A work unit may support 1+ goals but every important unit has a clear reason. One project-level conversation is used to:
- discuss intent;
- create Signals;
- ask about Work;
- add evidence or constraints;
- change priorities;
- respond to agent questions.
Conversation is not the system of record for execution state.
### Work cards
A Work card is the durable, inspectable representation of one desired outcome. It exposes definition, design, execution, evidence, delivery, and result without forcing the user to inspect raw agent transcripts.
### Attention queue
All decisions requiring a human are first-class objects:
- clarify intent;
- approve definition/design;
- grant permission;
- select an alternative;
- review a slice;
- approve merge/release;
- accept risk.
## 4. Canonical domain
### Signal ### Signal
Raw info that may matter: support ticket, customer comment, error spike, deploy failure, sales objection, team-channel message, analytics change, founder thought. **Not automatically a task.** Fates: attach to existing work, convert to candidate, save as knowledge, dismiss, mark irrelevant/duplicate. Evidence that may require attention.
```text
Signal
├── exact source/provenance
├── extracted meaning
├── project context
├── confidence
├── related signals
└── candidate Work links
```
A Signal MUST preserve the original source. A model summary never replaces evidence.
### Work
A durable desired outcome, not a message, issue, session, branch, or PR.
```text
Work
├── objective
├── reason/user impact
├── source Signals
├── Work Definition
├── Design Packet
├── slices/steps
├── runs/attempts
├── questions/decisions
├── artifacts/evidence
├── delivery
└── result
```
### Candidate work ### Work Definition
Proposed work unit not yet accepted as durable active work. Created when a signal/cluster seems actionable. Explains: proposed outcome, why it matters, supporting evidence, project/goal, system confidence, whether similar work exists. User can accept/edit/merge/defer/dismiss. A testable contract before implementation:
### Work unit - problem;
- desired outcome;
- affected users;
- in/out of scope;
- acceptance criteria;
- constraints;
- assumptions;
- unresolved questions;
- risk class;
- required artifacts;
- rollout/rollback requirements when applicable.
Central object. Persistent, stateful representation of one meaningful outcome. Owns: objective, desired result, scope/non-goals, current understanding, requirements, success criteria, status, priority, dependencies, blockers, decisions, evidence, plan, human actions, agent activity, artifacts, results, learning. ### Design Packet
Persists across conversations, agent runs, people, tools, time. Not identical to (though may contain): a single prompt, chat thread, agent session, PR, checklist item, branch. A reviewable implementation intent:
### Step - impact map;
- architecture summary/sequence;
- file-tree diff;
- call-flow diff;
- key types/interfaces;
- invariants;
- migration/security/operational concerns;
- vertical slice plan;
- explicit trade-offs and deviations.
Bounded action within a work unit: inspect logs, reproduce failure, draft plan, add tests, review preview, contact customers. Organizes execution but must not become another noisy task layer — visible when useful, hidden when only the outcome matters. ### Vertical Slice
### Run A bounded unit of implementation that produces observable, independently verifiable progress.
One agent/workflow attempt to progress a work unit: research root cause, implement, review, verify, prep release. A unit can have parallel/repeated runs. Subordinate to the work unit — never replaces it as the primary object. A valid slice answers:
- what behavior becomes observable;
- what code boundaries change;
- what artifacts must exist;
- what evidence proves completion;
- what review is required before continuation.
### Artifact ### Kit
Durable output/evidence: PR, commit, patch, design, spec, test report, screenshot, preview, research memo, customer-response draft, launch plan. Attached to the work unit, accessible from history and current state. A versioned executable configuration for resolving a class of Work:
### Decision - roles/agent definitions;
- tools and grants;
- skills/instructions;
- context policy;
- runtime requirements;
- harness policy;
- budgets;
- output contracts;
- verification recipes;
- escalation policy.
Records an important human/system choice + reasoning: approve plan, choose design direction, reject a migration, delay a feature, accept a limitation. Stays visible after the conversation so future agents don't repeat settled debates. A Kit is not an agent and not a run. v0 uses one static Coding Kit; dynamic Kit Builder comes after the loop is reliable.
### Approval ### Run and Attempt
Specific human permission before a sensitive/consequential action: merge code, deploy prod, send customer message, access sensitive data, spend money, change business policy. Precise, inspectable, bounded — user knows exactly what happens after approval. A Run coordinates execution for Work or a slice. An Attempt is one bounded execution by one harness in one environment.
### Result & learning Attempt terminal classifications:
Result records post-completion reality, comparing: intended outcome, expected impact, actual outcome, measurement period, unexpected effects, recommended follow-up. Learning = reusable knowledge extracted to improve future planning/execution across the relevant org/project. ```text
Succeeded | RetryableFailure | NeedsInput | Blocked
| VerificationFailed | BudgetExhausted | Cancelled | PermanentFailure
```
## 6. Work lifecycle “Agent working” is not a durable outcome.
Four conceptually consistent phases across all domains. ### Artifact and Evidence
| Phase | The system… | Goal/output | Examples:
| --- | --- | --- |
| **Discover** | Extracts, groups, dedupes, connects signals; proposes candidate work; relates to goals/projects. | Identify meaningful work with sufficient evidence/relevance — _not_ the largest backlog. |
| **Understand** | Gathers context — research, code/system inspection, customer evidence, analytics, prior decisions, constraints, open questions, risks, success criteria. | Actionable understanding, not an endless report. |
| **Create & iterate** | Coordinates production — code, specs, designs, docs, tests, customer comms, launch assets, research, business recommendations. Produces intermediate artifacts, verifies, incorporates feedback without losing continuity. | Working solution. |
| **Publish & learn** | Releases/delivers/shares, then follows the outcome — deploy status, error rates, adoption, customer reactions, conversion, support volume, revenue impact, qualitative feedback. | Measured result. |
Lifecycle ends only when work produces a result, is intentionally stopped, or is superseded. - plan/design;
- diff;
- test report;
- screenshot/video;
- commit;
- PR;
- preview;
- deployment;
- runtime logs;
- performance/security reports.
## 7. Work-unit boundaries Every artifact SHOULD include producer, Work/slice/run, source revision, environment, provenance, and verification status.
Quality depends on how work is divided. ### Result
**Good unit** = one independently meaningful outcome, summarizable in one clear sentence, reaches a recognizable result. Good: _fix Safari OAuth callback failures; decide SAML support this quarter; reduce onboarding time-to-first-value; prepare & launch team invitations._ Poor: _improve the product; marketing; work on onboarding; open the repo; read the logs_ (too broad or too granular). Delivery is not necessarily outcome. Result compares intended and actual impact:
**Split when:** multiple independent outcomes; different owners/approval paths; different orgs/permission boundaries; different deadlines/success criteria; one part finishes while another stays active; hard to summarize/review; a child deserves its own lifecycle/result. ```text
Achieved | PartiallyAchieved | NotAchieved | Regressed | Inconclusive
```
**Merge when:** two candidates describe the same outcome; different channels reported the same problem; one candidate is just evidence for an existing unit; neither can be completed independently. System may propose merges; uncertain merges stay reversible/user-reviewable. ### Learning
**Parent/child:** large initiatives contain child units; parent = larger result, each child still independently meaningful. _Parent: Restore OAuth reliability → children: fix Safari callbacks, improve setup diagnostics, update docs, notify customers, monitor post-release completion._ Parent summarizes progress/dependencies without replacing child detail. Post-work observations that may propose updates to:
## 8. Conversation model - project knowledge;
- architecture decisions;
- test recipes;
- skills;
- tools;
- Kit definitions;
- planning heuristics.
One persistent conversational surface, two scopes. Canonical knowledge updates require review.
| Scope | Use | ## 5. Quality doctrine
| --- | --- |
| **Global** | Think/act across workspace: introduce ideas/problems, ask what needs attention, compare work across projects, reprioritize, create/modify work, request summaries, discuss dependencies, request research/execution. System auto-connects messages to relevant projects/units while preserving source. |
| **Contextual** | Same composer focused on one work unit: add requirements, answer questions, review progress, change direction, approve a plan, request another attempt, ask for explanation, continue from an artifact. Entering does **not** create a new thread — it changes the active scope. |
Conversation is not the source of truth. Important facts/decisions/requirements/approvals/artifacts/results are extracted into structured objects while preserving links to source conversation. High-quality software is evaluated across:
## 9. Autonomous behavior | Dimension | Required question |
|---|---|
| Correctness | Does behavior satisfy the accepted criteria? |
| Regression safety | Did existing behavior remain valid? |
| Maintainability | Does the change preserve understandable boundaries? |
| Security | Are permissions, secrets, data, and dependencies safe? |
| Reliability | Are failures and edge cases handled? |
| Operability | Can it be deployed, observed, debugged, and rolled back? |
| Performance | Are expected resource limits preserved? |
| UX | Does the actual user path work? |
| Traceability | Can claims be tied to evidence and revisions? |
| Reversibility | Can risk be disabled or rolled back? |
The system continuously observes, understands, organizes, executes, escalates, learns. ### Completion rule
**May autonomously:** attach new evidence; update summaries/understanding; detect stale/blocked work; propose new work; run safe research; prepare plans/drafts; produce code/artifacts inside approved boundaries; run verification; suggest priorities; monitor completed work; create follow-up candidates; summarize activity. Work is complete only when:
**Request human for:** strategic direction; ambiguous product decisions; business tradeoffs; sensitive permissions; high-risk actions; external communication; production changes; priority conflicts; conflicting evidence; actions outside granted policy. 1. accepted behavior is implemented on a known revision;
2. required checks pass in a known environment;
3. design deviations are visible;
4. required human gates are satisfied;
5. delivery artifacts exist;
6. post-release observation is completed when required.
**Must not:** create speculative work without evidence; treat confidence as certainty; hide failures/uncertainty; silently cross org boundaries; reopen settled decisions without new evidence; perform irreversible actions without authority; optimize for activity over outcomes. A passing test suite is necessary but not sufficient for medium/high-risk work.
## 10. Human-in-the-loop ### Builder-verifier separation
User must not inspect every unit to find what needs attention. Human requests centralize into an **Attention Queue**. Each request states: what's required, why, the recommendation, supporting evidence, what happens after approval, what happens if the user does nothing, urgency, reversibility. **Categories:** decision · approval · clarification · access request · review · blocker resolution · risk alert. The implementation worker MUST NOT be the sole authority on completion.
Minimize unnecessary approvals via explicit standing policies while preserving clear control over sensitive actions. ```text
Builder → candidate output
Verifier → independent evidence
Resolver → completion decision
Human → policy-defined approvals
```
## 11. Provenance & trust ### Risk lanes
User must understand why the system believes something and what caused an action. **Every important claim carries provenance.** Sources: message, ticket, repo change, error report, analytics event, document, agent observation, human decision. Claim status distinguished as: verified · reported · inferred · assumed · conflicting · outdated. Primary interface stays readable; deeper evidence/derivation available on demand. **Never present an agent summary as equivalent to primary evidence.** #### Fast
## 12. Knowledge model Copy, docs, isolated styling, deterministic mechanical changes.
Accumulates several kinds; each retains its scope (personal prefs, project facts, org-wide truths never mixed blindly). ```text
short contract → implement → verify → PR
```
| Kind | Content | #### Standard
| --- | --- |
| **Canonical** | Reviewed descriptions: business, product, customers, design principles, architecture concepts, conventions, policies, goals. |
| **Operational** | Current structured state: active work, priorities, dependencies, ownership, blockers, approvals, results. |
| **Episodic** | History: conversations, runs, errors, decisions, reviews, releases, customer reactions. |
| **Learned patterns** | Reusable conclusions: common failure causes, customer prefs, repeated objections, effective launch patterns, known limitations, preferred technical/product approaches. |
## 13. Product boundaries Normal features, APIs, workflows, changes spanning a few modules.
| Boundary | Rule | ```text
| --- | --- | definition → combined design → 13 slices → verify → review → PR
| **Control layer, not tool replacement** | Specialist systems (source control, support, comms, analytics, billing, deployment, docs) keep native records/actions. Zopu provides the unified model: what matters, its state, next step, whether outcome worked. | ```
| **Own the work graph** | Connects organizations→projects→goals→signals→work units→dependencies→decisions→runs→artifacts→results→learnings. Durable source of coordination/context. |
| **Execution is replaceable** | Execution is a capability beneath the work unit. Users shouldn't care which agent/model/harness ran unless inspection is useful. |
| **No unnecessary AI config** | Users shouldn't normally choose model, thinking level, agent framework, context-window strategy, execution runtime, tool routing. System chooses by task/risk/cost/policy. |
## 14. Optimization #### Critical
**Optimize for: maximum useful progress with minimum required human attention.** Auth, billing, permissions, migrations, shared infrastructure, destructive operations.
Measures: correct extraction of meaningful work; low false-positive candidates; signal→understanding time; time to first useful action; accepted units reaching meaningful outcomes; reduced repeated context gathering; human attention per outcome; decision/output quality; trust/inspectability/reversibility; knowledge reuse; improvement from past results. ```text
definition approval → architecture approval → program-design approval
→ one slice at a time → staged release → observation/rollback gate
```
**Don't primarily optimize:** chat count, time in interface, task count, agent-run count, notification count, raw model usage. ## 6. Resolver contract
A successful day: user opens briefly, resolves a few high-value decisions, sees many units progressed correctly without them. The Work Resolver is the central product engine.
## 15. Business value It MUST continuously answer:
| Form | Value | - What remains incomplete?
| --- | --- | - What is executable now?
| **Execution capacity** | Small team progresses more work without proportional headcount. | - What capability can perform it?
| **Coordination reduction** | Handles repetitive context-gathering, state updates, linking, follow-ups, output prep. | - What evidence is missing?
| **Continuity** | Work keeps context, history, decisions, artifacts across tools/time. | - Should it retry, repair, replan, escalate, or stop?
| **Better opportunity capture** | Notices recurring problems, customer opportunities, operational risks, key relationships teams otherwise miss. | - Has the Work reached a valid terminal state?
## 16. Initial scope — first product loop Resolver outcomes:
1. User starts/connects a software project. ```text
2. Product establishes project context. VerifiedOutcomeDelivered
3. User describes desired work via global composer. VerifiedPRReady
4. System creates/updates a work unit. HumanDecisionRequired
5. Unit is clarified and accepted. ExternalDependencyBlocked
6. System progresses work autonomously. BudgetExhaustedWithEvidence
7. User reviews only necessary decisions/outputs. PermanentlyFailedWithDiagnosis
8. System produces verified artifacts. Cancelled
9. User approves publication/integration where required. ```
10. Product updates the unit and retains knowledge.
First version must prove persistent work units beat disposable agent chats. The Resolver MUST:
## 17. Long-term direction - operate from durable state;
- execute bounded attempts;
- enforce budgets and permissions;
- preserve provenance;
- recover after process failure;
- avoid duplicate side effects;
- require evidence before transitions;
- surface precise blockers;
- never silently stall.
Technical product is the starting point. The same work-unit model later supports: product research, marketing campaigns, content production, sales enablement, support operations, pricing work, business analysis, hiring, internal ops. Long-term: a general OS for creative/knowledge work. ## 7. Human operating model
**Enduring principles:** one persistent workspace · work units not chat sessions · outcome-oriented execution · invisible orchestration · explicit human authority · strong provenance · compounding organizational knowledge. Humans should spend attention on high-leverage decisions, not supervise tool calls.
## 18. Initial non-goals Preferred gates:
Not intended to be: a full external-tool replacement; a general social collaboration platform; a traditional kanban with AI decoration; a model playground; a prompt-management product; a marketplace of visible agents; a system that creates work faster than users can evaluate; a fully autonomous company with no human authority. - Work Definition approval;
- Design Packet approval for standard/critical work;
- slice review when risk requires it;
- merge/release approval;
- resolution of ambiguity or policy exceptions.
## 19. Principles A human request must include:
1. Organize around work, not conversations. ```text
2. Represent outcomes, not activity. decision needed
3. Keep the system calm while the machinery stays powerful. why it is needed
4. Bring humans in for judgment, not clerical coordination. recommended action
5. Expose evidence and uncertainty. alternatives
6. Make important actions understandable and reversible. consequences
7. Preserve organization and project boundaries. urgency
8. Prefer one coherent model over many disconnected features. affected Work/slice
9. Let knowledge compound across completed work. ```
10. Measure success by outcomes and reduced attention.
## 20. Working statement ## 8. External collaboration
Zopu Work OS is an autonomous OS for technical founders and small product teams. It observes work across the company, turns fragmented signals into persistent units of work, progresses them via agents and tools, and brings the human in only when judgment, permission, or direction is required. One continuous workspace moves from idea/problem to verified, shipped, measurable outcome while preserving context and building durable understanding over time. Buzz, Slack-like systems, Git forges, monitoring, support, and analytics are interfaces and Signal sources.
They MAY:
- create Signals;
- carry status updates;
- present questions;
- receive review/preview links;
- report incidents and results.
They MUST NOT own canonical Work, Resolver, verification, or completion state.
## 9. Product success metrics
Primary:
- verified outcomes per unit of human attention;
- median time from accepted Work to verified PR/outcome;
- percentage of Work reaching an explicit terminal state;
- verification escape rate;
- post-merge rework rate;
- design-review-to-implementation deviation rate.
Secondary:
- retry count;
- human interruption count;
- stale Work count;
- duplicate Signal/Work rate;
- average review-package comprehension time;
- Kit/version performance by work type.
Avoid optimizing token volume, agent count, or code generated.
## 10. Non-goals for early versions
- Fully autonomous company operation.
- Lights-off merging/deployment.
- Universal dynamic tool creation.
- Large unbounded fleets.
- Replacing Git or CI.
- Encoding all product truth in chat.
- Making one model/harness part of the product domain.
- Automating maintainability judgment without human policy.

57
docs/README.md Normal file
View File

@@ -0,0 +1,57 @@
# Zopu Work OS — Documentation Map
Dense context set for product development and coding agents.
## Read order
```text
1. agent-context.md — bootstrap and non-negotiables
2. product.md — product model and quality doctrine
3. glossary.md — canonical terminology
4. dev-loop.md — normative software delivery process
5. tech.md — architecture, actors, ports, runtimes
6. design.md — user experience
7. slices.md — sequential vertical product increments
8. dev-plan.md — repository execution plan/backlog
9. evaluation.md — quality measurement and improvement
```
## Use by role
| Role | Minimum context |
|---|---|
| Product/definition agent | agent-context, product, glossary, dev-loop |
| Architecture/design agent | agent-context, tech, dev-loop, current Work Definition |
| Coding agent | agent-context, current Design Packet/Slice, tech sections, repository rules |
| Verifier | agent-context, dev-loop, evaluation, Verification Plan |
| Frontend agent | agent-context, design, product, current slice |
| Lead/orchestrator | all documents, current repository state, active decisions |
## Document boundaries
```text
product.md what/why
tech.md system architecture
design.md interaction behavior
dev-loop.md how software Work is delivered
slices.md how Zopu product is incrementally built
dev-plan.md engineering order and release gates
evaluation.md how quality and improvements are measured
glossary.md exact term definitions
agent-context compact instructions for execution agents
```
## First build target
```text
message
→ Signal
→ approved Work Definition
→ approved Design Packet
→ one isolated implementation slice
→ independent verification
→ exact verified PR
→ human question/resume
```
Start at `dev-plan.md` backlog item 01. Do not begin dynamic Kit Builder or fleets before Slices 17 work reliably.

View File

@@ -1,88 +0,0 @@
# Zopu Integration Smoke
The smoke harness is a repeatable integration lane for the thin Zopu MVP. It probes the local web app, authenticated Convex control plane, Flue `project-manager` route, AgentOS daemon/workspace contract, the CPA OpenAI-compatible gateway, and the Orb execution-plane dependencies (Docker, OpenCode, Gitea, repository writability) before it creates any project issue.
The harness does not edit CPA configuration, restart services, print bearer tokens/API keys, or run parallel worker calls. `minimax-m3` is the serial worker-loop model; `glm-5.2` performs the plan and the independent review.
## Preflight checks
| Check ID | Layer | What it probes |
| --- | --- | --- |
| `web` | Control plane | Web app HTTP reachability |
| `convex` | Control plane | Convex health query returns OK |
| `organization` | Control plane | Authenticated personal organization |
| `project-contract` | Control plane | Disposable project exists with a connected source |
| `agentos-workspace-contract` | Control plane | Issue-scoped artifacts present |
| `agentos-daemon` | Control plane | Online local daemon |
| `flue` | Agent service | project-manager Flue route reachable |
| `cpa-model-catalog` | Model gateway | minimax-m3 and glm-5.2 in catalog |
| `cpa-completion-minimax-m3` | Model gateway | minimax-m3 accepts a no-tools completion |
| `cpa-completion-glm-5.2` | Model gateway | glm-5.2 accepts a no-tools completion |
| `model-roles` | Model gateway | Role selection contract (worker/planner-reviewer) |
| `worker-gateway-config` | Model gateway | AGENT_MODEL_BASE_URL matches the CPA base URL |
| `docker-daemon` | Orb execution plane | Docker daemon responds to `docker version` |
| `opencode-package` | Orb execution plane | `@agentos-software/opencode` is resolvable |
| `gitea-reachable` | Orb execution plane | Gitea `/api/v1/version` endpoint reachable |
| `gitea-creds-valid` | Orb execution plane | Gitea token authenticates against `/api/v1/user` |
| `gitea-pr-creation` | Orb execution plane | Target repository accessible for PR creation |
| `repository-writable` | Orb execution plane | Orb workspace root is writable |
A missing or unreachable dependency reports the check as failed with the exact reason. The harness never fakes success.
## Setup
Use a disposable/test project that already has a connected repository source and the issue-scoped artifact set. Export a Better Auth/Convex access token for the signed-in user, the local daemon id, and the CPA key/base URL. The CPA URL must include its OpenAI-compatible `/v1` path.
```bash
export ZOPU_SMOKE_ACCESS_TOKEN='...'
export ZOPU_SMOKE_PROJECT_ID='...'
export ZOPU_SMOKE_DAEMON_ID='local-macbook'
export ZOPU_SMOKE_CPA_BASE_URL='https://ai.example.invalid/v1'
export ZOPU_SMOKE_CPA_API_KEY='...'
```
For the Orb execution-plane checks (Docker, Gitea PR creation), also export:
```bash
export GITEA_URL='https://git.example.com'
export GITEA_TOKEN='...'
export ZOPU_SMOKE_REPOSITORY_PATH='owner/repo'
```
The harness reuses `AGENT_MODEL_*`, `CONVEX_URL`, `DAEMON_ID`, `SITE_URL`, and `VITE_FLUE_URL` when their `ZOPU_SMOKE_*` equivalents are absent. It never rewrites those values. Override the tiny feature request with `ZOPU_SMOKE_FEATURE_REQUEST` when the disposable project needs a more specific target.
## Commands
Run capability probes only:
```bash
bun run smoke:zopu
```
Drive one issue through the project loop and review it:
```bash
bun run smoke:zopu --run --report /tmp/zopu-smoke.json
```
The command prints one of these stable markers:
- `ZOPU_SMOKE_PREFLIGHT_PASSED` means all probes passed and no mutation was requested.
- `ZOPU_SMOKE_COMPLETED` means the issue was created, the worker reported completion, the durable issue reached `completed`, and GLM approved the result.
- `ZOPU_SMOKE_CONTRACT_BLOCKED` means a required capability or configuration is missing; the JSON report names the exact check and next action.
- `ZOPU_SMOKE_FAILED` means runtime execution started but did not satisfy the worker/reviewer contract.
Exit codes are `0` for a passing preflight or completed run, `2` for a contract block, and `1` for a runtime failure. Reports contain only endpoint labels, statuses, counts, ids, and sanitized failure text; model transcripts and credentials are intentionally omitted.
## Cleanup
The harness does not create Docker containers or Orb workspaces in preflight mode. In run mode, the Flue `project-manager` agent owns its sandbox lifecycle. To clean up Orb proof fixtures or run artifacts:
```bash
docker container prune -f --filter "label=orb"
rm -rf /tmp/orb-* /tmp/orb-proof-*
```
## Current lane boundary
The smoke intentionally stops before mutation when the project source, issue-scoped AgentOS artifacts, or online daemon contract is absent. The Orb execution-plane checks (Docker, OpenCode, Gitea) report BLOCKED when those external dependencies are not available on the local MacBook; this is expected and does not indicate an implementation failure. See [docs/DOGFOOD_V0.md](./DOGFOOD_V0.md) for the full lifecycle.

View File

@@ -1,251 +1,663 @@
# Zopu Work OS — Technical Architecture # Zopu Work OS — Technical Architecture
> Working spec · engineering/platform/infra/security/agent teams · starts from an existing Better-T-Stack monorepo (web + Expo + shared packages). > **Related:** `agent-context.md` defines agent rules; `dev-loop.md` defines orchestration; `glossary.md` defines terms.
**Objective.** Multi-tenant Work OS: durable orgs/projects/goals/work units/runs/artifacts/outcomes; one global conversation + scoped work-unit conversations; continuous candidate-work extraction from conversation and connected systems; coding agents in isolated reproducible environments; autonomous execution with explicit human-approval boundaries; agent progress streamed into durable state; inspectable artifacts (commits, PRs, test reports, previews, docs, reviews); scales across orgs/repos/concurrent units/environments; harnesses and model providers replaceable. > **Status:** Working technical source of truth
> **Audience:** engineers and implementation agents
> **Read after:** `product.md`
> **Principle:** durable intent/state is separate from disposable execution.
**Central ownership rule.** Work OS owns intent + durable state · Flow agents own orchestration · AgentOS owns agent runtime coordination · Harnesses own individual agent loops · Sandboxes own full-system execution · Git owns source history · Artifact storage owns generated outputs. ## 1. System topology
## 1. High-level architecture ```text
Web / Buzz / integrations
Control plane + execution plane.
```mermaid Application API + Convex durable data
flowchart TB
User --> Web[Web] & Mobile[Expo]
Web & Mobile --> API[App API] & Realtime Rivet actor system
API --> Auth[Identity/tenancy] & WorkGraph[Work graph] & Conversation & Policy ├── ProjectActor
Conversation & WorkGraph --> Flow[Flow Agent] ├── WorkActor
Flow --> Context & Scheduler & Policy & Gateway[Model gateway] & Integrations & Secrets[Secrets/workload identity broker] ├── AttemptActor
Scheduler --> Actor[AgentOS run actor] ├── VerificationActor (later split)
Actor <--> Harness[OpenCode/ACP] & Sandbox[Full Linux sandbox] ├── IntegrationActor (later)
Harness --> Gateway & Integrations └── ResultActor (later)
Sandbox --> Git & Runtime[Services/browsers/tests/builds]
Actor & Sandbox --> Secrets
Runtime --> Preview FLUE orchestration/application agents
Actor & Sandbox & Git --> Events[Event/provenance log] ├── Signal/Definition
Events --> WorkGraph & Realtime & Observability ├── Design/Slice planning
Artifact[Object storage] --> WorkGraph ├── Resolver decisions
Context & WorkGraph --> Search ├── Verification design/evaluation
Policy --> Notifications └── Learning synthesis
Ports
├── HarnessRuntime
├── SandboxRuntime
├── SourceControl
├── ArtifactStore
├── PreviewRuntime
├── SecretBroker
├── EventJournal
└── RuntimePolicy
Adapters
├── OMP/OpenCode/Codex/Pi/custom FLUE
├── CubeSandbox/AgentOS/persistent machine/Docker
├── Git forge
├── object storage
└── deployment/preview providers
``` ```
## 2. Components & ownership ### Ownership
**Client apps (web, Expo)** — product surfaces only: render org/project/work-unit/run/artifact/attention state; send domain commands via app API; subscribe realtime; upload via signed/brokered flows. Never reach model providers, Git internals, harnesses, or sandboxes directly. Commands: create work unit, continue work, change composer scope, approve plan, resolve blocker, review artifact, request changes, pause/cancel run, approve merge, approve deployment. | Layer | Owns |
|---|---|
| Work OS | durable intent, lifecycle, evidence, policy |
| Rivet actors | serialized ownership, recovery, timers, leases |
| FLUE | programmable orchestration and domain-specific agents |
| Harness | bounded coding/tool loop |
| Sandbox/runtime | filesystem, processes, network, isolation |
| Git | source revision history |
| Artifact store | durable outputs/evidence |
| External systems | collaboration, delivery, monitoring |
**Application API** — sole public backend: authz; org/project scoping; CRUD stable entities; command validation; conversation ingestion; attachment metadata; card/workspace/activity/attention queries; realtime-subscription authz; idempotency. Never expose internal runtime APIs. No harness, sandbox, or chat transcript owns Work state.
**Identity & tenancy** — one Work OS identity per user; organizations are hard tenant boundaries. Every durable/transient entity carries `organization_id` (+ `project_id`/`work_unit_id`/`run_id` where applicable). Deny-by-default authz on every command/query. Cross-org queries are explicit portfolio ops; never blend underlying knowledge/execution context. ## 2. Domain boundaries
**Secrets & workload-identity broker** — shared service owning short-lived run identities and scoped credential minting/injection for Git, model gateway, integrations, artifact storage, preview routing, and optional cloud access; secrets remain outside repositories/actor state, and broker decisions are policy-bound and auditable. Recommended packages:
**Work graph (canonical product DB)** — relational DB (authoritative) + event table (append-only provenance/replay) + materialized projections (cards, attention, portfolio, activity) + object storage (large artifacts) + search index. Entities: User, Organization, Membership, Project, Project source, Goal, Signal, Candidate, Work unit, Work-unit relation, Step, Run, Run attempt, Agent session, Blocker, Decision, Approval, Artifact, Source reference, Result, Learning, Conversation message, Composer scope, Integration connection, Policy, Workload identity. Work-unit record = structured fields + projections, **not** a big agent-authored JSON blob. ```text
packages/
├── signals/
├── work/
├── design/
├── planning/
├── kits/
├── resolver/
├── verification/
├── artifacts/
├── results/
├── knowledge/
├── runtimes/
├── harnesses/
└── integrations/
```
**Event & provenance log** — all meaningful changes are events. Envelope: Each domain SHOULD separate:
```json ```text
{ domain/ pure state, schemas, invariants
"event_id": "evt_01...", application/ use cases and orchestration
"type": "run.verification_completed", ports/ Effect services
"organization_id": "org_01...", adapters/ infrastructure implementations
"project_id": "prj_01...", ```
"work_unit_id": "wrk_01...",
"run_id": "run_01...", Avoid package proliferation in v0; logical boundaries may begin as folders.
"actor_type": "agent",
"actor_id": "opencode", ## 3. Core records
"source_type": "agentos_session",
"source_id": "session_01...", Minimal durable model:
"sequence": 184,
"payload": { ```ts
"summary": "All unit and browser tests passed", type Id = string
"artifact_ids": ["art_01..."]
}, interface Message {
"created_at": "2026-07-23T12:00:00Z" id: Id
projectId: Id
content: string
createdAt: number
}
interface Signal {
id: Id
projectId: Id
sourceType: string
sourceId: string
sourcePayloadRef?: string
summary: string
fingerprint: string
status: "candidate" | "accepted" | "dismissed"
}
interface Work {
id: Id
projectId: Id
title: string
objective: string
risk: "low" | "medium" | "high"
status: WorkStatus
definitionVersion?: number
designVersion?: number
createdAt: number
updatedAt: number
}
interface Step {
id: Id
workId: Id
sliceId?: Id
kind: "design" | "implement" | "verify" | "integrate" | "publish" | "observe"
objective: string
dependsOn: readonly Id[]
status: StepStatus
}
interface Run {
id: Id
workId: Id
stepId: Id
kitVersion: string
status: RunStatus
}
interface Attempt {
id: Id
runId: Id
number: number
harness: string
runtime: string
sourceRevision: string
status: AttemptStatus
startedAt?: number
endedAt?: number
}
interface Artifact {
id: Id
workId: Id
stepId?: Id
runId?: Id
attemptId?: Id
type: string
uri?: string
contentHash?: string
sourceRevision?: string
environmentId?: string
metadata: unknown
}
interface Question {
id: Id
workId: Id
stepId?: Id
attemptId?: Id
prompt: string
recommendation?: string
alternatives: readonly string[]
status: "open" | "answered" | "withdrawn"
answer?: string
} }
``` ```
Requirements: monotonic sequence per run/actor stream; idempotent ingestion; immutable original payload; normalized derived state; trace card→events; distinguish raw evidence vs agent interpretation vs human decision. All external/model payloads MUST be decoded with schemas at boundaries.
**Conversation service** — persistent conversation per user workspace, scoped. Message fields: org scope (+optional project/work-unit scope), author, content blocks, attachments, source references, extraction status, domain-event links. Persist exact user message; publish for extraction/orchestration; global + work-unit scope without new sessions; one message may reference several work units; preserve source links when structured state is extracted. ## 4. Work state machine
**Flow Agent (orchestration above specialist agents)** — interpret intent; extract signals/candidates; match new info to existing work; detect duplicates/relationships; build/update work briefs; decide whether a human question is required; select execution strategy; create runs; monitor run events; retry/fallback/escalate; summarize into work-unit state; create approval requests; propose follow-ups. Not the sole durable truth — outputs validated into commands/events. Does not directly mutate repos; coding is delegated to a harness in an isolated environment. ```text
Proposed
**Memory classes (four)** → Defining
→ AwaitingDefinitionApproval
- _Canonical project knowledge_ (versioned/reviewable, prefer in Git): `product.md`, `design.md`, `tech.md`, `business.md`, `AGENTS.md`, READMEs, decision records, repo-local conventions. → Designing
- _Structured operational memory_ (DB): goals, work-unit state, requirements, decisions, approvals, blockers, dependencies, results. → AwaitingDesignApproval
- _Episodic memory_ (events/transcripts): user messages, agent runs, tool calls, errors, reviews, deployments, human interventions. → Ready
- _Retrieval index_ (derived, non-authoritative → returns source refs): canonical docs, repo content, conversation, work-unit events, artifacts, connected external sources. → ExecutingSlice
→ VerifyingSlice
**Context assembly** — never inject all knowledge. Run context pack by precedence: (1) org policy (2) project goals + canonical context (3) repo conventions/instructions (4) work-unit objective/requirements/decisions (5) relevant evidence/artifacts (6) current run instructions (7) runtime + tool constraints. Each item retains source, scope, timestamp, confidence/verification status, revision id. Deterministic enough to audit/reproduce. → AwaitingSliceReview
→ IntegratedVerification
**Policy & approval engine** (above harness permissions + runtime capabilities) — per action: auto-allowed / allowed under standing policy / requires human approval / denied. Examples: create branch, push work branch, create PR → automatic; merge → approval; deploy preview → automatic; deploy production → approval; never send customer comms without review; never access prod data from a default coding run. Approval record = exact action, params, requested capability, evidence/rationale, risk level, expiration, user decision, resulting action event. → Review
→ Releasing
**Run scheduler / workflow engine** — converts accepted work → bounded runs. Owns queue, tenant quotas, priority, fairness, concurrency limits, run leasing, retry policy, budget allocation, cancellation, workflow checkpoints, waiting-for/resuming-after approvals. Run = idempotent steps; persist progress before waiting on human input. → Observing
→ Completed
**AgentOS runtime** — durable supervisor/harness runtime per active run. One actor per active run or isolated workbox (never one giant actor per user/org); durable run-environment identity; harness session lifecycle; event streaming; permission requests; lightweight fs/process coordination; sleep/wake when safe. Actor key: `org:{orgId}:project:{projectId}:work:{workUnitId}:run:{runId}`. Durable work unit survives actor destruction/replacement.
**Agent harness** — OpenCode is the initial coding harness (architecture supports other ACP-compatible/adapted harnesses later). Harness owns: model interaction loop, tool selection, repo inspection, file editing, shell requests, local reasoning state, session progress. Harness does **not** own: product work-unit state, org policy, billing, global memory, project permissions, long-term artifact retention. Drive OpenCode through AgentOS; never expose its standalone server to product clients.
**Full Linux sandbox** — compatibility/heavy-execution environment: repo checkout, writable branch/worktree, native binaries, package install, language runtimes, DBs, browser automation, dev servers, tests/builds, file watching, preview processes. AgentOS supervises via explicit adapters/bindings. Division:
```
AgentOS: durable run identity, harness session, approvals, lightweight coordination
Sandbox: full repo fs, native commands, services, browser, tests, builds
``` ```
Known first-party templates may run in lighter env; imported arbitrary repos default to full sandbox. Side/terminal states:
**Workbox** (product-neutral term for execution capsule beneath a run) — contains repo checkout, isolated branch, runtime recipe, harness config, project skills, temp credentials, running services, preview endpoints, test outputs. Ephemeral/replaceable; work unit is durable. Default isolation: **one writable checkout per active work unit / mutating run**. Parallel read-only runs may share immutable snapshots; parallel mutating runs use separate branches + separate Workboxes. ```text
NeedsInput | Blocked | Replanning | Failed | Cancelled
**Git (managed/connected)** — source history. Creation modes: managed template, connect external, import+mirror, managed-first then add external remote. Identity: one Work OS user identity, internal service identities for automation, repo-scoped short-lived credentials, no second visible Git account for users. Branching: `main` + `work/{W-id}/{slug}`. Git may be hidden in product but user retains portability (export, clone, patch download, external remotes).
**Project templates** — React web, Hono, Express, full-stack, API-only, Expo, monorepo starter. Template defines: source files, runtime versions, install/test/verify/dev commands, service ports, preview behavior, project context prompts, default policies, agent skills. Generates a reusable base snapshot after successful setup+verification.
**Project config files** — repo structure:
```
/ README.md, AGENTS.md, product.md, design.md, tech.md
/.zopu/ project.yaml, services.yaml, policies.yaml
lifecycle/{setup,resume,verify,preview} skills/{testing,reviewing,deploying}/SKILL.md
/apps/
``` ```
`project.yaml`: Transitions require explicit commands plus evidence. State MUST NOT be inferred from the latest text message.
```yaml ## 5. Actor model
name: example-project
runtime: { template: react-hono, node: "24", packageManager: pnpm } Start small.
commands:
{ ### ProjectActor
install: pnpm install --frozen-lockfile,
test: pnpm test, Owns:
verify: pnpm run verify,
dev: pnpm dev, - project configuration;
} - repository/runtime policies;
services: { web: { port: 3000 }, api: { port: 4000 } } - active Work index;
changes: { workflow: pull_request, baseBranch: main } - integration endpoints;
approvals: - project-level Signal routing.
{
pushBranch: automatic, ### WorkActor
createPullRequest: automatic,
mergePullRequest: required, Initial central aggregate:
deployPreview: automatic,
deployProduction: required, - Work Definition and versions;
} - Design Packet and versions;
- slice/step graph;
- Resolver state;
- approvals/questions;
- artifact references;
- result.
It serializes lifecycle transitions and commands.
### AttemptActor
Owns one execution attempt:
- runtime lease/sandbox ID;
- harness session;
- scoped credentials;
- event stream/checkpoints;
- cancellation;
- attempt timeout;
- normalized outcome.
### VerificationActor
Split from WorkActor after v0 verification is stable. Owns plan, checks, evidence, verdict.
### IntegrationActor
Added when multiple slices/branches exist. Owns branch composition, conflicts, integrated revision, integrated checks.
### ResultActor
Added after delivery observation exists. Owns rollout observation, original success signals, final outcome, learning proposals.
Do not create an actor per document or tool call. Create actors for durable identity, serialized ownership, independent failure/recovery, or timers.
## 6. Commands, events, and idempotency
Use command/event vocabulary rather than mutable agent prose.
Example commands:
```text
ProcessMessage
AcceptSignal
CreateWork
ApproveDefinition
ApproveDesign
StartNextSlice
RecordHarnessEvent
CompleteAttempt
StartVerification
RecordVerificationCheck
CreateRepairAttempt
PublishPullRequest
RecordHumanDecision
CompleteWork
``` ```
**Executor / integration gateway** — shared integration+tool gateway: GitHub, Slack, Linear, Intercom, analytics, CRM, cloud services, internal APIs, custom functions. Owns connection mgmt, tool catalog, scoped auth, tool-level policies, approval requirements, auditable invocation. Does **not** own: shell exec, repo fs ops, work-unit state, agent session state, sandbox lifecycle. Flow Agent may get broader org-level tools; OpenCode gets only project/run-scoped tools for the current task. Example events:
**Model gateway** — all model calls route here. Provider credentials, auto routing, tenant attribution, cost accounting, budget enforcement, rate limits, retries/fallback, prompt/response logging under policy, redaction, run-scoped credentials. Don't expose model selection by default. Run token (short-lived, scoped): `{organization_id, project_id, work_unit_id, run_id, model_classes:["coding-standard","coding-fast"], budget_usd:8, expires_in_seconds:3600}`. ```text
SignalCreated
**Artifact storage** (object storage, not actor state/DB rows) — types: patch, commit bundle, test report, build log, screenshot, video, preview snapshot, research doc, review report, generated file. Metadata: tenant/work-unit/run scope, type, content hash, producer, source event, retention policy, access policy. SignalAttached
WorkProposed
**Preview gateway** — controlled exposure (not raw sandbox ports): authenticated access, project/run-scoped routing, short-lived URLs, service health, automatic shutdown, event on ready/fail, screenshot/browser verification integration. DefinitionGenerated
DefinitionApproved
**Observability & billing** — every path emits logs, metrics, traces, cost, token use, runtime duration, tool calls, network use, artifact size, retry count, user-intervention count. Correlation dims: `organization_id, project_id, work_unit_id, run_id, actor_id, session_id, sandbox_id, sequence, timestamp`. Cost per successful outcome, not per model request. DesignGenerated
DesignApproved
## 3. Core execution flows SliceStarted
AttemptStarted
**Project onboarding.** User creates project → Project service creates/imports/connects repo → apply template (commit initial files) → Context service drafts context docs → user reviews/commits canonical docs → template runs setup+verify → Snapshot service creates sanitized base snapshot → project active. AttemptCompleted
VerificationPassed
**Prompt → candidate work.** User describes request → Conversation persists scoped message → publishes extraction job → Flow searches related work/sources → creates/updates signal → if existing work matches: attach signal + update understanding; else if new outcome: create candidate → Work graph publishes materialized update → realtime shows updated/new card. VerificationFailed
QuestionOpened
**Accepted work → verified PR.** User approves candidate → accept work unit → Flow persists objective + acceptance criteria → Scheduler creates implementation run → AgentOS provisions run actor → Workbox creates isolated sandbox → clone repo + create work branch → start OpenCode session → send run context pack. **Execution loop:** harness inspects/edits/executes, streams events/requests to AgentOS, which persists meaningful events → work card updates. On completion: run verification → upload reports/screenshots → commit/push branch → Flow creates PR + attaches artifacts + moves to Review → UI shows PR/preview/evidence/human action. QuestionAnswered
PullRequestCreated
**Approval continuation.** Harness requests sensitive action → AgentOS → Policy evaluates action/scope: auto-allow · or → creates approval request to attention queue → user sees action/evidence/risk/impact → approve/modify/reject → policy persists decision → AgentOS resumes with bounded permission · or → deny with reason. WorkCompleted
## 4. State machines
Keep product, run, and environment state separate. Frontend primarily exposes work-unit state; run state = operational detail; Workbox state hidden except debugging/delayed startup.
```
Work unit: proposed → clarifying → ready → active → waiting|review|monitoring → done
modifiers: blocked, waiting_on_user, waiting_on_dependency, approval_required, autonomous, at_risk, scheduled
Run: queued → provisioning → preparing → running → waiting_for_input|verifying → succeeded|failed|cancelled
Workbox: creating → preparing → ready → busy → sleeping|waking → destroyed
``` ```
## 5. Concurrency model Every side-effecting command MUST include an idempotency key. Suggested key:
- One writable checkout per active mutating run; one branch per independently shippable work unit. ```text
- Serialize mutating runs against same branch; permit parallel read-only analysis on immutable snapshots; permit parallel child work on separate branches; merge/integration = explicit controlled action. <workId>:<commandType>:<logicalVersion>
- A work unit may use multiple runs: research → implementation → automated review → verification → human review.
- Never let agents share one writable working dir without deliberate coordination + conflict handling.
## 6. Persistence & recovery
Durable truth lives outside transient agent processes. Persist after every meaningful step: work-unit state changes, decisions, approval requests, agent summaries, audit-relevant tool results, commit/patch checkpoints, artifact metadata, verification results. Don't rely on running shells, in-memory harness state, open sockets, or uncommitted working dirs surviving. Before waiting for human input: persist findings, commit relevant files, upload artifacts, record exact requested decision, store resumable workflow state. All workflow steps idempotent or safely repeatable.
## 7. Security model
**Trust boundaries** — untrusted: imported repos, repo instructions, external docs, customer messages, MCP tools, tool responses, generated code, agent-authored summaries. A model recommendation is **not** an authorization decision.
**Layered authorization** — sensitive action requires ALL: `product policy AND harness permission AND runtime capability AND scoped credential`. Path: agent requests → harness permission check → Work OS policy eval → human approval when required → runtime capability check → credential minting → execution → provenance event.
**Workload identity** — each run gets short-lived identity: org_id, project_id, work_unit_id, run_id, initiating principal, permitted capabilities, expiration. Workbox exchanges it for scoped Git/model-gateway/integration/artifact/preview/cloud access. Never inject long-lived org credentials into sandboxes.
**Network policy** — deny-by-default or allowlisted by run type (package registry during setup, repo host, model gateway, approved external APIs; no unrestricted prod network by default).
**Secrets** — stored outside repos/actor state; org/project scoped; minted/injected only when required; short-lived; redacted from logs/transcripts; rotatable without rebuilding projects; audited on access.
**Data isolation** — every query/event/artifact/context lookup/runtime token is tenant-scoped. Cross-org portfolio views may aggregate summaries but never give one org's raw context to another org's agent run.
**Human approvals** required for: prod deployment, merging protected branches, accessing sensitive prod data, external communications, billing/financial changes, data deletion, org-level policy changes. Action-specific; expire after use or time.
**Auditability** — retain: who initiated, context provided, agent+model class, tools called, files changed, approvals requested/granted, artifacts produced, what was published/merged, outcome observed.
## 8. Scaling model
Scale via many small isolated actors + queued runs (not long-lived per-user machines).
**Tenant scheduler** enforces: max concurrent runs per org/project, priority classes, fair scheduling, subscription limits, model/sandbox budget, retry/runtime-duration limits. **Work queue** states: `accepted → queued → leased → running → completed`. Leases idempotent + recoverable (prevent duplicate expensive execution). **Resource limits** (hard + soft): tokens/run, cost/run, runtime duration, tool calls, file count + repo size, network bytes, process count, memory/CPU, artifact size, subagent depth, repeated-failure count. **Artifact offloading** — large logs/binaries → object storage; keep refs in work graph. **Snapshots** — sanitized reusable snapshots accelerate setup. Layers: base OS → language/toolchain → template → project deps. Never snapshot: long-lived creds, user secrets, active tokens, unreviewed prod data. **Multi-region** (not MVP unless demanded) — keep control-plane relational consistency explicit: org home region, region-local execution actors + artifact/cache placement, global routing to org's control plane, explicit data-residency restrictions.
## 9. Better-T-Stack monorepo mapping
```
apps/ web, expo, api, worker, agent-runtime, preview-gateway
packages/ db, auth, domain, api-contract, events, realtime, work-graph, conversation,
flow-agent, context, policy, scheduler, integrations, model-gateway, git,
artifacts, sandbox, agentos, opencode, observability, billing, ui, config
``` ```
Boundaries: **domain**=pure types + state transitions · **events**=envelopes/schemas/reducers/provenance · **work-graph**=work-entity queries + materialized views · **conversation**=message persistence/scopes/extraction jobs/source links · **flow-agent**=orchestration/planning/run coordination · **context**=canonical loading/retrieval/context-pack construction · **policy**=evaluation + approval workflows · **scheduler**=queues/leases/quotas/workflows/retries/cancellation · **agentos**=client/actor naming/session lifecycle/event translation · **opencode**=harness config/session prompts/tool policy/result normalization · **sandbox**=provider adapters/workspace lifecycle/commands/logs/previews · **git**=repo provisioning/branches/commits/pushes/PRs/mirrors · **integrations**=executor connection mgmt + tool scopes · **model-gateway**=provider routing/budgets/credentials/cost attribution. External artifact creation stores provider ID before transition completion to prevent duplicate PRs/deployments.
## 10. API & command design ## 7. Effect service boundaries
Prefer domain commands over generic entity mutation. Examples: `CreateProject, ImportRepository, CreateWorkUnit, AcceptCandidate, AttachSignal, MergeCandidates, StartWork, UpdateRequirements, ApprovePlan, ResolveBlocker, RequestChanges, PauseRun, CancelRun, ApproveAction, RejectAction, ApproveMerge, ApproveDeployment, ReopenWorkUnit`. Each command: validate tenant scope + current state; idempotent where practical; emit ≥1 domain event; no direct client control of infra details. Keep domain/application code provider-neutral.
## 11. Event translation (harness → product) ```ts
interface HarnessRuntime {
open(input: OpenHarnessInput): Effect.Effect<HarnessSession, HarnessError>
prompt(id: string, content: string): Effect.Effect<void, HarnessError>
events(id: string): Stream.Stream<HarnessEvent, HarnessError>
approve(input: PermissionDecision): Effect.Effect<void, HarnessError>
abort(id: string): Effect.Effect<void, HarnessError>
close(id: string): Effect.Effect<void, HarnessError>
}
Preserve raw harness events for audit; translate to human-meaningful product events: interface SandboxRuntime {
create(spec: SandboxSpec): Effect.Effect<SandboxLease, SandboxError>
exec(lease: SandboxLease, cmd: Command): Effect.Effect<CommandResult, SandboxError>
readFile(lease: SandboxLease, path: string): Effect.Effect<Uint8Array, SandboxError>
writeFile(lease: SandboxLease, path: string, body: Uint8Array): Effect.Effect<void, SandboxError>
pause(lease: SandboxLease): Effect.Effect<void, SandboxError>
resume(id: string): Effect.Effect<SandboxLease, SandboxError>
terminate(lease: SandboxLease): Effect.Effect<void, SandboxError>
}
``` interface SourceControl {
tool_call bash "pnpm test" → run.verification_started prepareWorktree(input: WorktreeInput): Effect.Effect<Worktree, GitError>
tool_result exit_code=0 → run.test_suite_passed diff(worktree: Worktree): Effect.Effect<DiffArtifact, GitError>
permission_request git_push → approval.requested commit(input: CommitInput): Effect.Effect<CommitArtifact, GitError>
file_edit src/auth/callback.ts → artifact.code_change_updated push(input: PushInput): Effect.Effect<BranchArtifact, GitError>
createPullRequest(input: PullRequestInput): Effect.Effect<PullRequestArtifact, GitError>
}
interface VerificationRuntime {
execute(plan: VerificationPlan, env: EnvironmentRef):
Effect.Effect<VerificationResult, VerificationError>
}
``` ```
Primary UI hides raw transcripts unless user opens technical details. Also define:
## 12. Implementation sequence ```text
ArtifactStore | SecretBroker | EventJournal | PreviewRuntime | RuntimePolicy
```
- **Phase 1 — Durable product model:** orgs, projects, work units, candidates, runs, artifacts, conversation scopes, event log, card projections. Use `Layer` for adapters, `Scope` for leases/processes, `Stream` for events, `Schedule` for bounded retries, and supervised fibers for long-running consumers. Pin Effect version and isolate beta API churn.
- **Phase 2 — One complete coding loop:** one template project, managed Git, one sandbox provider, one AgentOS actor/run, OpenCode harness, model gateway, branch creation, tests, artifact upload, PR output, human approval.
- **Phase 3 — Context & memory:** canonical Markdown files, context-pack construction, retrieval index, decision memory, source provenance.
- **Phase 4 — Autonomous discovery:** continuous signal ingestion, candidate extraction, duplicate detection, existing-work matching, attention queue.
- **Phase 5 — Additional integrations/workflows:** external Git, support, team comms, issue trackers, analytics, deployment + monitoring.
## 13. MVP success criteria ## 8. FLUE responsibilities
Reliably demonstrate: `Prompt → candidate/work unit → clarified objective → isolated coding run → tested change → preview/evidence → pull request → human review → durable completed work card`. The work unit must remain understandable + resumable even if the harness session or Workbox is destroyed. Use FLUE for domain-specific agents/workflows:
## 14. Non-goals (first release) - conversation response and Signal proposal;
- Work Definition compiler;
- impact analysis;
- architecture/program design;
- vertical-slice planning;
- Resolver decision support;
- verification-plan generation;
- maintainability review;
- result/learning synthesis.
Every harness; every sandbox provider; arbitrary multi-agent swarms; full replacement of external Git providers; multi-region active-active control plane; unlimited imported-repo compatibility; automatic prod deployment without approval; generic low-code workflow builder; exposing model selection + agent internals to users. FLUE output MUST be typed proposals/commands, validated by application policy.
## 15. Architectural principles FLUE MUST NOT directly:
1. Durable work state outlives execution environments. 2. Every execution path is tenant-scoped. 3. One writable checkout per mutating unit of work. 4. Persist events before projecting summaries. 5. Treat agent outputs as claims with provenance, not unquestioned truth. 6. Separate product policy, harness permission, and runtime capability. 7. Short-lived credentials + explicit workload identity. 8. Keep harnesses, models, sandboxes, and Git providers replaceable. 9. Hide infrastructure state from the product surface unless actionable. 10. Measure successful outcomes and human attention, not merely agent activity. - mutate arbitrary database tables;
- bypass actor lifecycle;
- grant itself tools;
- merge/deploy outside policy;
- mark Work complete without evidence.
## 9. Harness strategy
Harness is replaceable:
```text
HarnessRuntime
├── OmpHarnessLive
├── OpenCodeHarnessLive
├── CodexHarnessLive
├── PiHarnessLive
└── FlueHarnessLive
```
v0 selects one harness. Prefer mature coding harnesses for repository exploration/edit/test loops; use custom FLUE agents for product-specific reasoning.
Zopu owns Work, branches, worktrees, budgets, approvals, artifacts, and completion. Harness owns one bounded implementation loop.
## 10. Runtime strategy
### CubeSandbox
Best for full Linux execution:
- native binaries;
- Bun/Node/Python;
- browsers;
- databases/services;
- project builds/tests;
- pause/resume;
- strong isolation.
Use E2B-compatible SDK behind `SandboxRuntime`. OMP runs as a process inside the Cube microVM.
### AgentOS
Best for:
- lightweight durable agent environments;
- ACP-integrated software;
- actor-adjacent orchestration;
- context/files/networking that fit runtime limits.
Use an attached full sandbox when native/heavy tooling is needed.
### Persistent project machine
Later optimization for long-lived caches, large repositories, and developer-customized environments. Use Git worktrees per active Work. Do not make it the only isolation boundary.
### Runtime selection
The Resolver requests capabilities:
```text
writable repo, Bun, browser, PostgreSQL, 8 GB RAM, network policy
```
`RuntimePolicy` chooses provider. Product logic never branches on Cube/AgentOS directly.
## 11. Repository isolation
Initial safe model:
```text
one project repository mirror
one Git worktree per active Work/slice
one mutating attempt per worktree
```
Rules:
- attempts receive scoped worktrees;
- parallel mutating attempts use separate branches;
- credentials are short-lived and repository-scoped;
- host home directories are never mounted;
- model credentials are run-scoped;
- untrusted code runs in sandbox;
- output commits record base and candidate SHA.
## 12. Planning and design artifacts
Required for standard work:
```text
WorkDefinition
ImpactMap
DesignPacket
VerticalSlicePlan
VerificationPlan
```
Program design SHOULD include:
- expected file-tree delta;
- expected call-flow delta;
- key types/signatures;
- dependency direction;
- invariants;
- security/data boundaries;
- known deviations.
The verifier compares candidate code against this design, but metrics are evidence, not absolute truth.
## 13. Verification architecture
Verification layers:
```text
Static
├── format/lint/typecheck
├── dependency policy
├── secret scan
└── static security
Behavior
├── unit
├── integration
├── contract
└── property tests where useful
Product
├── browser/user flow
├── screenshots/video
├── accessibility
└── visual checks
Operational
├── build/start/health
├── migration/rollback
├── logs
└── resource limits
Design
├── expected vs actual files/interfaces
├── dependency graph delta
├── design deviations
└── maintainability review
```
A `VerificationResult` MUST bind checks to candidate commit and environment.
Repair loop:
```text
failure evidence → bounded repair attempt → clean verification rerun
```
Tests added by the implementation SHOULD fail on the base revision and pass on the candidate when practical.
## 14. Delivery and integration
Publishing order:
```text
slice checks pass
→ integrated candidate created
→ impacted checks on integrated SHA
→ commit/push
→ PR
→ review package
```
Never create a “verified PR” from a different SHA than the verified candidate.
Review package:
- original intent;
- approved definition/design;
- slice narrative;
- meaningful diffs;
- screenshots/video;
- verification evidence;
- deviations/risks;
- exact commit/PR.
Manual merge remains policy in v0.
## 15. Preview and release
Preview is an artifact, not an open random port. `PreviewRuntime` may use:
- sandbox-exposed app;
- agentOS Apps for compatible generated HTTP apps;
- external staging/deployment.
Production release requires explicit policy, rollout plan, health signals, and rollback trigger.
## 16. Security baseline
- private control-plane endpoints;
- authenticated Cube/Rivet APIs;
- scoped runtime tokens;
- no long-lived model/Git secrets in workspace files;
- network egress policy;
- artifact access authorization;
- immutable audit trail for approvals;
- tool allowlist per Kit;
- destructive tools denied by default;
- merge/deploy human-gated initially;
- cleanup of terminated sandboxes and credentials.
## 17. Observability
Record product-level events, not only infrastructure logs.
Required dimensions:
```text
projectId workId sliceId runId attemptId actorId
kitVersion harness runtime model baseSha candidateSha
```
Track:
- state-transition latency;
- attempt duration/outcome;
- retries/replans;
- verification checks;
- human wait time;
- token/compute cost;
- duplicate side effects;
- abandoned/stale Work;
- post-release failures.
Raw harness logs are retained as artifacts; UI consumes normalized events.
## 18. Initial deployment shape
```text
Web + API + Convex
Bun/Effect daemon
Rivet cluster/actors
├── FLUE agents/workflows
└── SandboxRuntime
└── CubeSandbox on dedicated/VDS host
└── OMP + repo + tests
```
Keep Cube control APIs private; expose only authorized preview paths. Rivet and execution daemon may share the VPS initially but remain separate deployable processes.
## 19. Technical acceptance for v0
The architecture is proven when one real repository supports:
```text
message → Signal → approved Work → approved Design Packet
→ one slice → isolated harness run → independent verification
→ verified commit → real PR → human response/resume
```
With:
- durable recovery;
- no duplicate Work/PR;
- bounded retries;
- exact evidence;
- cancellation;
- explicit terminal states.

328
docs/agent-context.md Normal file
View File

@@ -0,0 +1,328 @@
# Zopu Work OS — Agent Context
> **Purpose:** compact bootstrap context for Codex/OMP/OpenCode/FLUE workers contributing to Zopu.
> **Usage:** read this first, then load only task-relevant documents and repository files.
## 1. Mission
Build Zopu: a Work OS that turns conversation and external Signals into durable, verified outcomes.
```text
Signal → Work → Definition → Design → Vertical Slices
→ Resolver → Attempts → Verification → Delivery → Result → Learning
```
The product is not chat threads, an issue tracker, a harness UI, or an autonomous PR factory.
## 2. Source-of-truth order
```text
1. current accepted Work/Question/Decision
2. approved Work Definition
3. approved Design Packet + current Slice
4. repository tests/types/code
5. product.md
6. tech.md
7. design.md
8. dev-loop.md
9. slices.md
10. dev-plan.md
11. glossary.md
12. evaluation.md
```
When sources conflict, report the conflict. Do not silently choose a convenient interpretation.
## 3. Current product target
Initial user:
```text
technical founder / small engineering team
one project + one Git repository
web chat + Work cards
manual merge
```
Initial complete loop:
```text
message
→ Signal
→ approved Work
→ approved Design Packet
→ one bounded coding slice
→ independent verification
→ exact verified commit
→ real PR
→ human intervention/resume
```
## 4. Non-negotiable invariants
1. Work is a durable outcome, not a chat/session/issue/PR.
2. Exact Signal provenance is preserved.
3. State transitions are explicit commands/events.
4. FLUE/model output is validated proposal data, not authority.
5. Rivet actors own serialized lifecycle/recovery.
6. Harnesses and runtimes are replaceable adapters.
7. One mutating Attempt owns one isolated worktree.
8. Every Attempt is bounded and terminally classified.
9. Builder output remains candidate output until independent verification.
10. Evidence binds to exact revision and environment.
11. Published PR head equals verified integrated SHA.
12. Human questions/approvals are durable objects.
13. Retry/restart must not duplicate Work, commits, PRs, or deployments.
14. Merge/deploy remain human-gated initially.
15. Completion requires outcome evidence, not an agents “done.”
## 5. System ownership
```text
Work OS durable intent/state/evidence/policy
Rivet actors identity, serialization, timers, recovery
FLUE domain agents/workflows and typed proposals
Harness bounded coding/tool loop
Sandbox filesystem/process/network isolation
Git source history
ArtifactStore durable output/evidence
UI/Buzz interaction surfaces, not workflow truth
```
## 6. Initial actor shape
```text
ProjectActor
├── project config + active Work index
WorkActor
├── definition/design/slices
├── resolver state
├── questions/approvals
├── artifacts/result
AttemptActor
├── runtime lease
├── harness session
├── normalized events
├── timeout/cancellation/outcome
```
Add Verification/Integration/Result actors only when their lifecycles justify separation.
## 7. Initial adapters
Preferred first implementation:
```text
FLUE definition/design/resolver support
Rivet actors
CubeSandbox full Linux runtime
OMP first coding harness, replaceable
Git forge branch/commit/PR
Convex durable application data/projections where used
Effect application ports/layers/errors/scopes/streams
```
Do not import provider-specific types into domain modules.
## 8. Working protocol for every code task
### Before editing
1. Identify current vertical slice and acceptance criteria.
2. Read relevant docs and repository rules.
3. Inspect current architecture, tests, and existing abstractions.
4. State impacted modules and risks.
5. Produce/confirm a small implementation plan.
6. Ask a Question only when ambiguity changes behavior, security, data, or architecture.
### During implementation
1. Stay within the current slice.
2. Preserve dependency direction.
3. Prefer existing patterns over parallel frameworks.
4. Add typed boundaries and tagged errors.
5. Make external effects idempotent.
6. Add cancellation/timeouts for long-running operations.
7. Record exact revisions/provider identifiers.
8. Add tests with the implementation.
9. Do not weaken unrelated tests or hide failures.
10. Explain intentional Design Packet deviations.
### Before declaring candidate ready
Run task-relevant checks:
```text
format/lint/typecheck
focused unit/integration tests
behavioral/live check
security/secret check where relevant
design-conformance review
```
Report:
```text
files changed
behavior implemented
checks run + results
known risks/deviations
artifacts/revision
remaining blockers
```
Never claim verified unless the independent verification path ran.
## 9. Code organization rule
Use logical separation:
```text
domain/ pure types/state/invariants
application/ use cases/commands
ports/ Effect services
adapters/ FLUE/Rivet/Cube/OMP/Git implementations
```
Keep the initial package count practical. A folder boundary is enough until independent reuse/lifecycle exists.
## 10. State modeling rule
Prefer explicit state machines over booleans.
Bad:
```ts
isRunning: boolean
isDone: boolean
hasError: boolean
```
Good:
```ts
type AttemptStatus =
| "queued"
| "running"
| "succeeded"
| "retryable_failure"
| "needs_input"
| "blocked"
| "verification_failed"
| "budget_exhausted"
| "cancelled"
| "permanent_failure"
```
Transitions must validate current version/state.
## 11. Model/agent output rule
Agents return schemas such as:
```text
SignalProposal
WorkDefinitionProposal
DesignPacketProposal
ResolverDecisionProposal
VerificationAssessment
LearningProposal
```
Application code decodes, authorizes, and executes commands. Agents do not directly mutate arbitrary state.
## 12. Quality rule
For every behavior, identify:
```text
acceptance criterion
implementation evidence
independent check
exact candidate revision
review requirement
```
Tests prove immediate behavior. Design review protects maintainability and future change cost. Both matter.
## 13. UI rule
Expose:
```text
outcome
phase
latest meaningful update
next action/blocker
slice progress
evidence
delivery/result
```
Hide low-level harness noise by default. Preserve raw logs in diagnostics.
## 14. Scope control
Do not implement unless required by the current slice:
- dynamic Kit Builder;
- multiple harness providers;
- large fleets;
- autonomous merge/deploy;
- universal workflows;
- automatic canonical-memory mutation;
- sophisticated multi-tenancy.
Design replaceable boundaries, then ship one path.
## 15. Task completion template
```md
## Implemented
- ...
## Behavior
- ...
## Verification run
- `command` — pass/fail
- ...
## Evidence
- candidate SHA/artifacts
- ...
## Design deviations
- none / ...
## Remaining risks or blockers
- none / ...
```
## 16. Required reading by task type
| Task | Read |
|---|---|
| product/domain | product.md, glossary.md |
| UI | design.md, product.md |
| actors/state | tech.md, glossary.md |
| orchestration | dev-loop.md, tech.md |
| current sequencing | slices.md, dev-plan.md |
| verification/evals | evaluation.md, dev-loop.md |
| provider adapter | tech.md plus provider docs |
| broad feature | agent-context.md + all relevant sections |
## 17. Stop and escalate when
- accepted definition/design is missing or contradictory;
- required permission is unavailable;
- worktree/revision identity is uncertain;
- a destructive action is outside policy;
- verification cannot bind to exact candidate;
- implementation requires expanding scope materially;
- a retry would repeat a non-transient failure;
- repository state suggests another active mutating owner.
Return a precise Question or blocker, not a guess.

233
docs/deployment.md Normal file
View File

@@ -0,0 +1,233 @@
# Deployment Notes
Two active environments share one Convex deployment (`dev:befitting-dalmatian-161`).
Each runs its own web server, Flue agents process, and `.env` file.
## Environments
| | Local Mac Dev | Cheaptricks Staging |
| --- | --- | --- |
| SSH alias | (local) | `cheaptricks` |
| Repo path | `/Users/puter/Workspace/zopu/code` | `/workspace/code` |
| Tailscale IPv4 | `100.101.157.28` | `100.122.185.111` |
| Web URL | `http://100.101.157.28:5173` | `https://zopu.cheaptricks.puter.wtf` |
| Flue URL (internal) | `http://100.101.157.28:3585` | `http://127.0.0.1:3585` |
| Flue URL (browser-facing) | `http://100.101.157.28:3585` | `https://zopu.cheaptricks.puter.wtf/api` |
| Caddy | none (direct Tailscale) | `zopu.cheaptricks.puter.wtf` HTTPS termination |
| Bun | installed directly | symlink at `/workspace/.bun` |
| Process manager | manual `bun run dev:tailscale:*` | `nohup` into `/tmp/zopu-*.log` |
## Shared Convex deployment
Deployment name: `dev:befitting-dalmatian-161`
Convex URL: `https://befitting-dalmatian-161.convex.cloud`
Convex Site URL: `https://befitting-dalmatian-161.convex.site`
Convex env vars are deployment-scoped, not per-machine. Authenticate from any
machine with `npx convex dev` inside `packages/backend`.
Key Convex env var:
```
SITE_URL = <the origin Better Auth should trust>
```
This controls `trustedOrigins` in the Better Auth config
(`packages/backend/convex/auth.ts`). It must match the URL the browser
actually visits, or sign-in fails silently with a CORS rejection.
When switching between environments:
```bash
cd packages/backend
# For local Mac testing:
npx convex env set SITE_URL 'http://100.101.157.28:5173'
# For Cheaptricks staging:
npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf'
```
## Local Mac Dev `.env`
```env
CONVEX_DEPLOYMENT=dev:befitting-dalmatian-161
CONVEX_URL=https://befitting-dalmatian-161.convex.cloud
CONVEX_SITE_URL=https://befitting-dalmatian-161.convex.site
SITE_URL=http://100.101.157.28:5173
NATIVE_APP_URL=code://
VITE_CONVEX_URL=https://befitting-dalmatian-161.convex.cloud
VITE_CONVEX_SITE_URL=https://befitting-dalmatian-161.convex.site
VITE_FLUE_URL=http://100.101.157.28:3585
DAEMON_ID=local-macbook
DAEMON_NAME=Local MacBook
DAEMON_VERSION=0.0.0
DAEMON_HEARTBEAT_MS=15000
DAEMON_COMMAND_LEASE_MS=60000
FLUE_DB_TOKEN=<from secrets manager>
AGENT_MODEL_PROVIDER=xiaomi
AGENT_MODEL_NAME=mimo-v2.5
AGENT_MODEL_API=openai-completions
AGENT_MODEL_BASE_URL=<cheaptricks gateway base url>
AGENT_MODEL_API_KEY=<cheaptricks api key>
AGENT_MODEL_CONTEXT_WINDOW=1048576
AGENT_MODEL_MAX_TOKENS=131072
GITEA_URL=https://git.openputer.com
GITEA_TOKEN=<gitea personal access token>
```
### Start local dev
```bash
bun run dev:tailscale:agents -- --port 3585 &
bun run dev:tailscale:web &
```
Both bind `0.0.0.0` so they are reachable over Tailscale from a phone.
Before testing locally, flip the Convex `SITE_URL`:
```bash
cd packages/backend && npx convex env set SITE_URL 'http://100.101.157.28:5173'
```
## Cheaptricks Staging `.env`
Located at `/workspace/code/.env` on the `cheaptricks` host.
```env
CONVEX_DEPLOYMENT=dev:befitting-dalmatian-161
CONVEX_URL=https://befitting-dalmatian-161.convex.cloud
CONVEX_SITE_URL=https://befitting-dalmatian-161.convex.site
SITE_URL=https://zopu.cheaptricks.puter.wtf
NATIVE_APP_URL=code://
VITE_CONVEX_URL=https://befitting-dalmatian-161.convex.cloud
VITE_CONVEX_SITE_URL=https://befitting-dalmatian-161.convex.site
VITE_FLUE_URL=https://zopu.cheaptricks.puter.wtf/api
VITE_ZOPU_SERVER_URL=https://zopu.cheaptricks.puter.wtf/api
PORT=3590
DAEMON_ID=local-macbook
DAEMON_NAME=Local MacBook
DAEMON_VERSION=0.0.0
DAEMON_HEARTBEAT_MS=15000
DAEMON_COMMAND_LEASE_MS=60000
FLUE_DB_TOKEN=<from secrets manager>
AGENT_MODEL_PROVIDER=xiaomi
AGENT_MODEL_NAME=mimo-v2.5
AGENT_MODEL_API=openai-completions
AGENT_MODEL_BASE_URL=https://ai.cheaptricks.puter.wtf/v1
AGENT_MODEL_API_KEY=<cheaptricks api key>
AGENT_MODEL_CONTEXT_WINDOW=1048576
AGENT_MODEL_MAX_TOKENS=131072
GITEA_URL=https://git.openputer.com
GITEA_TOKEN=<gitea personal access token>
```
### Caddy config
File: `/etc/caddy/Caddyfile` on `cheaptricks`
```
zopu.cheaptricks.puter.wtf {
bind 135.181.82.179 2a01:4f9:c013:4a64::1
encode zstd gzip
handle_path /api/* {
reverse_proxy 127.0.0.1:3585
}
reverse_proxy 127.0.0.1:5173
}
```
`/api/*` routes to the Flue agents process (port 3585).
Everything else routes to the web dev server (port 5173).
Reload after changes:
```bash
sudo systemctl reload caddy
```
### Vite allowedHosts
`apps/web/vite.config.ts` must include `server: { allowedHosts: true }` or
Vite rejects requests arriving through the Caddy domain.
### Start staging dev
```bash
ssh cheaptricks
export PATH=$PATH:/workspace/.bun/bin
cd /workspace/code
# Pull latest
git pull origin feat/web-integrarion
bun install
# Start both processes with nohup so they survive SSH disconnect
nohup bun run dev:tailscale:agents -- --port 3585 > /tmp/zopu-agents.log 2>&1 &
nohup bun run dev:tailscale:web > /tmp/zopu-web.log 2>&1 &
```
Before testing on staging, flip the Convex `SITE_URL`:
```bash
cd packages/backend && npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf'
```
### Verify staging
```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://zopu.cheaptricks.puter.wtf/
# expect: 200
curl -sS -D - -o /dev/null \
'https://befitting-dalmatian-161.convex.site/api/auth/get-session' \
-H 'Origin: https://zopu.cheaptricks.puter.wtf' | grep access-control-allow-origin
# expect: access-control-allow-origin: https://zopu.cheaptricks.puter.wtf
```
## Model configuration
Both environments use the same model via the Cheaptricks AI gateway:
- Provider identity: `xiaomi` (Flue catalog maps this to MiMo multimodal metadata)
- Model: `mimo-v2.5`
- API protocol: `openai-completions`
- Context window: `1048576`
- Max output tokens: `131072`
- Multimodal: text + image input
The `AGENT_MODEL_BASE_URL` differs:
- Local Mac: uses the external gateway URL
- Cheaptricks: uses `https://ai.cheaptricks.puter.wtf/v1` (local to the box)
## Known gotchas
1. **Convex SITE_URL is single-valued.** Only one origin can be trusted at a
time. Switch it when moving between local and staging, or sign-in breaks
silently with a CORS error in the browser console.
2. **Vite blocks unknown hosts by default.** Caddy domain must be allowed
via `server.allowedHosts` in `apps/web/vite.config.ts`.
3. **Flue port changed from 3583 to 3585.** The old Caddy config pointed at
3583/13100. Current ports are 3585 (Flue) and 5173 (web).
4. **`.env` is gitignored.** Each machine maintains its own copy. The repo
ships `.env.example` as the template.
5. **Convex CLI auth is per-machine.** Run `npx convex dev` once inside
`packages/backend` on each new machine to authenticate the CLI.

705
docs/dev-loop.md Normal file
View File

@@ -0,0 +1,705 @@
# Zopu Work OS — Software Development Loop
> **Purpose:** canonical operating process for producing high-quality software with humans and agents.
> **Applies to:** Zopu product development and software Work resolved by Zopu.
> **Core principle:** accelerate implementation without removing design judgment, independent verification, or release learning.
## 1. The model
Software delivery contains two nested loops.
### Outer product loop
```text
Observe problem
→ define outcome
→ design solution
→ deliver slices
→ release
→ observe result
→ learn/revise
```
### Inner slice loop
```text
Select approved slice
→ prepare isolated candidate
→ implement
→ verify behavior
→ verify design conformance
→ review/resteer
→ integrate
```
The outer loop proves that the right problem was solved. The inner loop proves that each implementation increment is correct and preserves the intended system shape.
The system optimizes:
```text
verified progress / human-attention minute
```
It does not optimize commits, tokens, agents, PR count, or code volume.
## 2. Why this process exists
Agent harnesses compress implementation time but do not remove these failure modes:
- ambiguous requirements implemented literally;
- locally correct code that damages system boundaries;
- horizontal plans whose behavior is visible only at the end;
- tests that pass while the user path is wrong;
- builder self-certification;
- parallel changes that fail when integrated;
- merged code that does not solve the original problem;
- lessons lost after delivery.
Therefore decisions move earlier and implementation is divided into reviewable vertical slices.
## 3. Delivery routes
Risk determines ceremony.
### Fast lane
Use for isolated, reversible, deterministic changes.
```text
Work Definition
→ compact design note
→ implement
→ automated verification
→ PR
```
Required human gate: merge or policy exception.
### Standard lane
Use for normal product behavior, APIs, UI flows, and multi-module changes.
```text
Work Definition
→ impact analysis
→ program design
→ 14 vertical slices
→ slice verification/review
→ integrated verification
→ PR
```
Required gates: definition/design approval and merge.
### Critical lane
Use for authentication, authorization, billing, migrations, destructive operations, secrets, shared infrastructure, or high blast radius.
```text
Work Definition approval
→ architecture approval
→ program-design approval
→ one slice at a time
→ security/rollback verification
→ staged release approval
→ observation
```
A risk classifier may recommend a lane; policy makes the final decision.
## 4. Phase A — Problem and product definition
### Inputs
- user conversation;
- Signals and provenance;
- incidents/analytics/support;
- project strategy and constraints;
- existing related Work.
### Required artifact: Work Definition
```text
problem
affected users
desired observable outcome
success signals
in scope / out of scope
acceptance criteria
constraints
assumptions
unresolved questions
risk lane
required artifacts
release/rollback requirements
```
### Rules
- Acceptance criteria describe behavior, not implementation.
- Assumptions are explicit and labeled.
- Important ambiguity becomes a Question, not hidden prompt context.
- The original Signal remains attached.
- A changed definition creates a new version and invalidates dependent approvals.
### Gate
Proceed when the definition is testable, important questions are resolved, and required approval exists.
## 5. Phase B — System impact and architecture
### Goal
Determine how the change interacts with the existing system before code is generated.
### Required artifact: Impact Map
```text
directly changed components
potentially affected components
data/security boundaries
public contracts
migrations and compatibility
operational effects
required verification categories
```
### Architecture artifact
Use only the detail needed for the Work:
- component/sequence diagram;
- API/event contracts;
- data ownership;
- trust and permission boundaries;
- migration/rollout shape;
- failure and recovery paths.
Architecture describes component interaction. It does not replace program design.
### Gate
Proceed when affected boundaries and externally visible contracts are understood.
## 6. Phase C — Program design
### Goal
Make implementation intent cheap to review before code volume exists.
### Required artifact: Design Packet
```text
architecture summary
expected file-tree delta
expected call-flow delta
key types/interfaces/signatures
dependency direction
invariants
error model
state transitions
security/operational concerns
trade-offs
known deviations
```
Example file-tree intent:
```diff
packages/resolver/
+ domain/resolution-decision.ts
+ application/resolve-next-slice.ts
+ ports/harness-runtime.ts
+ adapters/omp-harness-live.ts
```
Example call-flow intent:
```text
WorkActor.start
→ resolveNextSlice
→ AttemptActor.start
→ SandboxRuntime.create
→ HarnessRuntime.open/prompt
→ normalize outcome
→ VerificationRuntime.execute
→ WorkActor.transition
```
### Review questions
- Is the dependency direction correct?
- Does one module own each invariant?
- Is the next likely change still local?
- Is complexity introduced where it belongs?
- Are persistence and external side effects idempotent?
- Are failure, cancellation, and recovery explicit?
- Can the behavior be verified independently?
### Gate
Standard/critical Work requires an approved Design Packet. Small fast-lane Work may use a compact design note.
## 7. Phase D — Vertical slice planning
### Goal
Turn design into increments that are observable, executable, and reviewable.
### Slice contract
```text
objective
user/system behavior exposed
planned code boundaries
inputs/dependencies
expected outputs/artifacts
verification checks
review requirement
budget/retry policy
```
### Valid slice
A slice:
- crosses the layers needed to expose behavior;
- produces evidence before later slices;
- can be reverted or revised without discarding the full implementation;
- is small enough for one focused review;
- does not postpone all integration/testing until the end.
### Invalid horizontal plan
```text
build all database code
→ build all backend code
→ build all frontend code
→ test at end
```
### Preferred plan
```text
Slice 1: one durable Work transition visible in UI
Slice 2: one isolated execution attempt visible in UI
Slice 3: independent verification visible in UI
Slice 4: verified PR artifact visible in UI
```
Agents should receive only the current approved slice plus necessary surrounding context, not an invitation to redesign the whole system.
## 8. Phase E — Kit compilation
### Goal
Compile approved intent into an executable, policy-bound package.
### Inputs
```text
Work Definition
Design Packet
current Slice
project/repository policy
risk lane
tool/runtime/harness registry
previous evidence and decisions
```
### Output: immutable Execution Kit version
```text
roles
context selection
skills/instructions
tool grants
runtime capabilities
harness selection
source-control policy
output contracts
verification recipe
budgets/timeouts
human gates
escalation policy
```
v0 uses a static Coding Kit. Dynamic selection/composition follows only after repeated runs establish useful patterns.
### Kit invariants
- least-privilege tools;
- no self-granted capabilities;
- explicit model/runtime/harness;
- bounded compute and retries;
- exact expected artifacts;
- verifiers separated from builders;
- version recorded on every Run.
## 9. Phase F — Slice execution
### Preparation
The Resolver:
1. confirms the slice and design versions are current;
2. resolves dependencies;
3. creates Run and Attempt records;
4. prepares an isolated worktree/base SHA;
5. provisions runtime;
6. injects scoped context and credentials;
7. starts the selected harness.
### Implementer contract
The implementer receives:
```text
current slice only
accepted Work Definition
relevant Design Packet excerpt
repository rules
completion/evidence contract
known previous failures/decisions
explicit forbidden actions
```
It may inspect surrounding code as needed but must not expand scope silently.
### Normalized execution events
```text
MeaningfulProgress
ToolStarted/Completed
QuestionRaised
ArtifactProduced
CandidateReady
AttemptFailed
AttemptCancelled
```
Raw provider events remain diagnostics, not product state.
### Attempt terminal outcomes
```text
Succeeded
RetryableFailure
NeedsInput
Blocked
VerificationFailed
BudgetExhausted
Cancelled
PermanentFailure
```
No attempt remains indefinitely active. Timeouts and process death are classified outcomes.
## 10. Phase G — Independent verification
### Principle
Candidate output is not complete output.
### Verification layers
#### Static
- format/lint/typecheck;
- dependency policy;
- secret/security scan;
- generated-file consistency.
#### Behavioral
- focused unit/integration/contract tests;
- new tests fail on base and pass on candidate where practical;
- service startup and live API assertions;
- database/migration behavior.
#### Product
- real browser/user path;
- screenshots/video;
- accessibility/visual checks;
- frontend/backend interaction.
#### Operational
- build/start/health;
- logs and failure handling;
- resource limits;
- rollout/rollback checks.
#### Design
- expected vs actual files/interfaces/call paths;
- dependency graph changes;
- duplicated logic;
- unjustified casts/exceptions;
- explicit deviations from approved design.
### Verification record
Every check binds to:
```text
Work/slice/run/attempt
candidate SHA
environment/runtime
command or assertion
exit/status
evidence artifact
timestamp
verifier identity/version
```
### Repair loop
```text
failed evidence
→ Resolver classifies
→ bounded repair attempt
→ implementer receives exact failure
→ full required verification reruns
```
Passing only the failed command is insufficient when the candidate changed.
## 11. Phase H — Slice review and resteering
Policy decides whether human review is required after each slice.
### Review package
```text
slice objective
approved design intent
actual behavior
meaningful code narrative
verification evidence
design deviations
risks/questions
recommended decision
```
### Decisions
```text
Accept slice
Request repair
Revise design
Split/reorder slices
Stop/cancel Work
```
Resteering updates durable definition/design/slice versions. It is not an informal chat instruction lost inside a harness transcript.
## 12. Phase I — Integration
Individually valid slices may be invalid together.
### Integration flow
```text
compose candidate commits
→ resolve conflict/overlap
→ produce integrated SHA
→ run impacted + required checks
→ generate integrated review package
```
### Invariants
- the PR head equals the verified integrated SHA;
- checks identify exact revision/environment;
- overlapping assumptions are surfaced;
- no slice bypasses integrated verification;
- failures return to repair/replan, not silent merge.
## 13. Phase J — Delivery
### Delivery artifacts
```text
commit
branch
pull request
preview/staging URL
release notes
migration/rollback plan
```
Creating a PR is a delivery transition, not completion of the original outcome.
### Human review
The review experience should explain:
1. original intent;
2. accepted behavior;
3. architecture/program design;
4. slice narrative;
5. important diffs;
6. evidence;
7. deviations/risks;
8. exact revision and next action.
Manual merge remains the initial policy.
## 14. Phase K — Release and observation
For releasable Work:
```text
verified candidate
→ preview/staging
→ approval
→ rollout
→ health observation
→ accept or rollback
```
Observation compares:
```text
expected success signal
vs
actual production/user result
```
Final classifications:
```text
Achieved
PartiallyAchieved
NotAchieved
Regressed
Inconclusive
```
Failure or unexpected behavior creates new Signals linked to the original Work.
## 15. Phase L — Learning
After terminal Work, evaluate both output and process.
### Questions
- Was intent captured correctly?
- Did design predict implementation?
- Which slice boundaries worked poorly?
- Which attempts/retries occurred and why?
- Which context/tools were missing?
- Which verification found defects?
- Which defects escaped?
- Did human review request major changes?
- Did release achieve the result?
- Should project knowledge, a Kit, skill, tool, or policy change?
### Outputs
Reviewable proposals:
```text
knowledge update
architecture decision
test recipe
Kit/tool/skill revision
planning heuristic
follow-up Work
```
No automatic mutation of canonical knowledge.
## 16. Human attention model
Humans intervene at policy-defined gates, not every tool action.
### High-value gates
```text
definition approval
design approval
risk/permission exception
slice review
merge/release approval
result acceptance
knowledge update approval
```
### Question format
```text
decision required
why it blocks progress
recommended answer
alternatives
consequences
affected Work/slice/version
```
Answers are durable Decisions and resume the same Work.
## 17. Resolver decision table
| Condition | Resolver action |
|---|---|
| executable slice exists | start bounded Attempt |
| transient infrastructure/model failure | retry by policy |
| candidate fails checks | create repair Attempt with evidence |
| design appears invalid | enter Replanning |
| required information missing | open Question; enter NeedsInput |
| dependency unavailable | enter Blocked with recheck/escalation |
| budget exhausted | terminal outcome with evidence |
| all slices pass | integrated verification |
| integrated candidate passes | publish/review |
| release requires observation | enter Observing |
| result established | complete and synthesize learning |
## 18. Failure and recovery rules
- Durable state is written before external effects.
- External provider IDs are persisted before acknowledging completion.
- Commands are idempotent.
- Actor/process restart resumes from checkpoints.
- Leases expire and are reclaimed.
- Sandbox/harness termination is best-effort plus reconciliation.
- Stale callbacks are rejected by version/attempt identity.
- Every terminal failure includes diagnosis, evidence, and recommended next action.
## 19. Anti-patterns
Do not:
- send a vague issue directly to a fleet;
- let the harness own Work state;
- equate tests passing with maintainability;
- generate all layers before exposing behavior;
- let the builder be sole verifier;
- hide design changes inside implementation;
- merge independently verified branches without integrated checks;
- create duplicate PRs/deployments on retry;
- mark Work complete at PR creation;
- auto-update canonical knowledge from one run;
- add agents because parallelism appears impressive.
## 20. Definition of a high-quality delivered Work
```text
✓ original evidence preserved
✓ outcome and acceptance criteria approved
✓ risk lane correct
✓ impact/design reviewed as required
✓ vertical slices independently demonstrated
✓ every Attempt terminal and auditable
✓ candidate verified independently
✓ design deviations explicit
✓ integrated SHA verified
✓ review package understandable
✓ delivery/release policy satisfied
✓ actual result assessed
✓ learning captured as proposals
```
This document is the normative delivery process. `slices.md` defines how Zopu itself is built incrementally; this file defines how Zopu and its agents should build software.

445
docs/dev-plan.md Normal file
View File

@@ -0,0 +1,445 @@
# Zopu Work OS — Development Plan
> **Related:** `slices.md` defines product increments; `dev-loop.md` defines the normative delivery loop; `evaluation.md` defines promotion gates.
> **Purpose:** concrete implementation order for the repository.
> **Strategy:** one vertical product loop, contracts first, provider adapters second, generalization last.
## 1. Target demonstration
Use one real repository and one deterministic Work:
> Add `GET /health` returning `{ status: "ok", commit: "<sha>" }`, with automated and live HTTP verification, then create a verified PR.
The first release is successful only when the full path works from chat to exact PR evidence.
## 2. Engineering rules
1. Land domain contracts before parallel adapter/UI work.
2. Keep FLUE, Rivet, Cube, AgentOS, and harness types behind ports.
3. One side effect has one idempotency key.
4. One mutating attempt owns one worktree.
5. Every state transition has a command, event, actor, timestamp, and evidence reference.
6. Every async process has timeout, cancellation, retry policy, and terminal classification.
7. Builder output is candidate output until independent verification passes.
8. Do not generalize until two real use cases require it.
9. Do not add an agent role without an evaluation showing quality gain.
10. Keep manual merge/deploy gates initially.
## 3. Repository workstreams
Recommended logical ownership:
```text
A. Domain/actors
B. FLUE definition/design agents
C. Resolver/runtime/harness
D. Verification/Git
E. Web experience
F. Deployment/operations
```
Only parallelize after shared schemas and event contracts are merged.
## 4. Phase 0 — Baseline and contracts
### Deliverables
- repository architecture inventory;
- one canonical glossary;
- schemas for Message, Signal, Work, WorkDefinition, DesignPacket, VerticalSlice, Run, Attempt, Artifact, Question;
- Work/Attempt state machines;
- command/event list;
- port interfaces;
- static `CodingKitV0`;
- deterministic fixture project/task.
### Tests
- schema round trips;
- state transition property/table tests;
- idempotency tests;
- fake adapters;
- actor restart/replay tests.
### Exit gate
A fake end-to-end test can create Work, approve definition/design, run fake slices, verify, and produce a fake PR artifact.
## 5. Milestone A — Work exists (Slices 12)
### Issue order
1. Persist project messages.
2. Implement `ProcessMessage`.
3. FLUE structured Signal/Work proposal.
4. Signal fingerprinting and attach/create rules.
5. WorkActor/projection.
6. Reactive Work card.
7. Work Definition schema/versioning.
8. Definition compiler agent.
9. definition edit/approval/questions UI.
10. approval invalidation tests.
### Release gate
```text
message → exact Signal → proposed card → approved Work Definition
```
No runtime integration.
## 6. Milestone B — Work is executable (Slices 35)
### Issue order
1. ImpactMap/DesignPacket schemas.
2. design compiler and slice planner.
3. design review/version UI.
4. Resolver decision function as pure domain logic.
5. WorkActor resolver commands/events.
6. FakeHarness/FakeSandbox adapters.
7. AttemptActor lifecycle.
8. CubeSandbox adapter.
9. selected harness adapter (OMP first unless spike rejects it).
10. Git worktree preparation.
11. normalized activity stream.
12. cancellation/timeout/cleanup.
### Contract freeze before adapter work
```text
HarnessEvent
SandboxLease
AttemptInput/Outcome
Artifact metadata
```
### Release gate
```text
approved design → one real slice → isolated repository changes
```
No PR claim yet.
## 7. Milestone C — Work is trustworthy (Slices 67)
### Issue order
1. VerificationPlan schema.
2. command-check runner.
3. HTTP-check runner.
4. revision/environment binding.
5. verifier role/process.
6. repair-attempt loop.
7. design-conformance evidence.
8. Git commit/push adapter.
9. PR creation adapter with idempotency.
10. review-package generator/UI.
11. exact-SHA invariant tests.
### Required quality fixture
For the health task, prove:
```text
new test fails on base
candidate passes focused tests
service starts
GET /health returns expected body
candidate SHA equals PR head SHA
```
### Release gate
A human can review and merge a real verified PR without reading raw agent logs.
## 8. Milestone D — Work is steerable (Slice 8)
### Issue order
1. Question/Decision lifecycle.
2. harness permission/question normalization.
3. attention UI.
4. contextual answer routing.
5. same-session resume and restarted-session recovery.
6. stale-question/version protections.
### Release gate
A deliberate ambiguity blocks Work, receives an answer, and resumes without duplicate execution.
## 9. Milestone E — Work survives complexity (Slices 910)
### Issue order
1. multi-slice commit model.
2. IntegrationActor/use case.
3. conflict/overlap analysis.
4. integrated verification.
5. preview adapter.
6. release/rollback policy.
7. observation and WorkResult.
8. incident/result-to-Signal loop.
### Release gate
Two slices pass independently, integrate on one SHA, publish preview, and produce an observed result.
## 10. Milestone F — System improvement (Slices 1112)
### Issue order
1. post-run evaluation schema.
2. learning synthesis.
3. knowledge proposal/diff workflow.
4. Kit registry/versioning.
5. Kit compiler.
6. tool/skill registry.
7. role evaluation harness.
8. controlled parallel fleet.
9. policy fallback to static Kit.
### Release gate
A completed run proposes a reviewable Kit/knowledge improvement, and a second run demonstrably benefits.
## 11. Actor rollout
### Initial
```text
ProjectActor
WorkActor
AttemptActor
```
WorkActor contains Resolver state and verification orchestration initially.
### Split only when justified
```text
VerificationActor — independent check lifecycle becomes complex
IntegrationActor — multi-branch/slice composition exists
ResultActor — deployment observation exists
ResolverActor — scheduling lifecycle needs independent ownership
```
Avoid actor-per-record designs.
## 12. Test architecture
### Domain
- transition tables;
- invariants;
- retry/budget logic;
- dependency graph readiness;
- approval/version invalidation;
- completion rules.
### Application
- command → event → projection;
- idempotent side effects;
- crash/restart recovery;
- timeout/cancellation;
- stale command rejection.
### Adapter contract tests
Run same suite against fake and live adapters:
```text
HarnessRuntime
SandboxRuntime
SourceControl
ArtifactStore
```
### End-to-end
Maintain fixtures:
```text
happy path
casual message/no Work
duplicate message
definition revision
design rejection
harness transient failure
human question/resume
verification repair
retry exhaustion
PR duplicate callback
actor restart mid-attempt
cancellation
```
### Quality evaluation
Before adding a new model/role/Kit, compare:
- success rate;
- escaped defect rate;
- review changes requested;
- retries;
- human attention;
- cost/time;
- design deviation.
## 13. Operational rollout
### Local
- fake adapters;
- local Rivet/AgentOS;
- one local repository fixture.
### Internal VPS
- deployed Rivet actors;
- Bun/Effect daemon;
- private Cube API;
- one OMP template;
- test forge repository.
### Dogfood repository
- real branch/PR;
- manual review;
- no production deploy;
- collect every failure.
### Controlled release
- one project/user;
- quotas;
- kill switch;
- audit trail;
- explicit external side-effect gates.
## 14. Security checklist before real repositories
```text
private control-plane networking
Cube/Rivet authentication
short-lived Git/model credentials
sandbox egress policy
no host HOME mounts
tool allowlist
artifact authorization
secret redaction
attempt timeout/cleanup
manual merge/deploy
audit approvals
```
## 15. Branch/PR sequencing
Suggested integration branch:
```text
dogfood/v0
```
Per-slice branches:
```text
dogfood/s01-work
dogfood/s02-definition
...
```
Within a slice, parallel branches may cover:
```text
contracts/domain
backend/actor
frontend
adapter
tests
```
Merge order:
```text
contracts → domain/actor → adapters/UI → integration tests
```
Every branch must target the current slice integration branch, not independently invent shared schemas.
## 16. First backlog
Execute exactly in this order:
```text
01 glossary + schemas
02 Work/Attempt state machines
03 commands/events/idempotency
04 fake E2E harness
05 message persistence
06 Signal extraction
07 Work card
08 Work Definition
09 definition approval
10 Design Packet
11 vertical-slice planner
12 design approval
13 Resolver with fake harness
14 AttemptActor
15 CubeSandbox adapter
16 OMP adapter/spike
17 real repository mutation
18 VerificationRuntime
19 repair loop
20 Git commit/push/PR
21 review package
22 contextual question/resume
23 integration verification
24 preview/release/result
25 learning proposals
26 dynamic Kit Builder
```
## 17. First production-quality acceptance test
```text
Given:
- one configured project/repository;
- one actionable user message;
- no pre-existing Work.
When:
- Zopu processes the message;
- the user approves definition/design;
- the Resolver executes all slices;
- verification passes;
- publication is requested.
Then:
- one Signal and one Work exist;
- exact provenance is preserved;
- every attempt has a terminal outcome;
- one verified candidate SHA exists;
- all required checks bind to that SHA;
- one PR exists at that SHA;
- the Work card explains intent, design, changes, evidence, and next action;
- actor/process restarts produce no duplicate Work, attempts, commits, or PRs.
```
## 18. Stop conditions
Pause feature expansion when any is true:
- stale “running” Work exists;
- duplicate external side effects occur;
- verification cannot identify exact SHA/environment;
- human cannot understand why a Work is “done”;
- retries are unbounded;
- agents bypass actor/application commands;
- provider-specific assumptions leak into domain;
- new roles add cost without measured quality improvement.
Fix the completion system before adding more autonomy.

405
docs/evaluation.md Normal file
View File

@@ -0,0 +1,405 @@
# Zopu Work OS — Evaluation and Improvement
> **Purpose:** measure whether Zopu produces better software and improve agents, Kits, tools, prompts, and workflow without regressions.
> **Principle:** no autonomy increase without evidence.
## 1. Evaluation targets
Evaluate five layers separately:
```text
A. Intent quality
B. Design quality
C. Execution reliability
D. Software quality
E. Product outcome
```
A fast agent that generates more code but increases review/rework is a regression.
## 2. Core metrics
### Intent
- Signal precision/recall on labeled messages;
- duplicate/incorrect Work rate;
- acceptance-criteria correction rate;
- unresolved ambiguity discovered after implementation.
### Design
- human design revision rate;
- implementation deviation rate;
- missed impacted components;
- slice replanning rate;
- architecture-review issues discovered after coding.
### Execution
- terminal Work percentage;
- stale-running count;
- attempt success/retry/failure distribution;
- restart recovery success;
- duplicate side effects;
- cancellation cleanup;
- time/cost per verified slice.
### Software quality
- verification pass rate on first candidate;
- escaped defect/regression rate;
- review changes requested;
- test weakening incidents;
- security/maintainability findings;
- integration-only failures;
- post-merge rollback/hotfix rate.
### Human experience
- attention minutes per verified outcome;
- blocking questions per Work;
- time to understand review package;
- percentage of recommendations accepted;
- user override/correction rate.
### Outcome
- achieved/partial/failed/inconclusive distribution;
- time from merge to result;
- original success signal movement;
- follow-up incident rate.
## 3. Evaluation corpus
Maintain a versioned fixture corpus.
### Message/Signal fixtures
```text
casual conversation
clear actionable request
multiple intents
duplicate request
new evidence for existing Work
conflicting requirement
incident/error Signal
```
### Software Work fixtures
```text
small isolated bug
API behavior addition
frontend/backend vertical flow
cross-package refactor
auth/permission change
database migration
parallel-slice conflict
verification repair
human question/resume
release regression
```
Each fixture includes:
```text
repository/base SHA
input Signals
expected Work Definition properties
risk lane
expected impacted areas
required behavior/evidence
forbidden outcomes
human gold review notes
```
## 4. Evaluation modes
### Deterministic unit/property evaluation
For:
- schemas;
- state transitions;
- dependency readiness;
- idempotency;
- budgets/retries;
- approval invalidation;
- actor recovery.
Must run on every change.
### Adapter contract evaluation
Run the same suite against fake and live:
```text
HarnessRuntime
SandboxRuntime
SourceControl
ArtifactStore
PreviewRuntime
```
### Agent offline evaluation
Replay fixture inputs against candidate agent/Kit versions. Store all outputs and compare with current production baseline.
### End-to-end sandbox evaluation
Run real repository tasks in isolated environments, verify exact candidate, and score review quality.
### Dogfood/online evaluation
Observe real Work with human decisions, merge outcomes, incidents, and post-release results.
## 5. Scoring rubric
Score each candidate run 03 per dimension.
| Score | Meaning |
|---|---|
| 0 | unsafe/incorrect/missing |
| 1 | substantial human repair required |
| 2 | acceptable with minor correction |
| 3 | correct, complete, reviewable |
Dimensions:
```text
intent fidelity
scope control
design correctness
slice quality
implementation correctness
test quality
maintainability
security/reliability
evidence completeness
review clarity
result achievement
```
Critical safety/invariant violation is automatic failure regardless of aggregate score.
## 6. Golden invariants
Automated assertions:
```text
no duplicate Signal/Work/PR/deployment
no Work stuck active beyond lease/timeout
no verification without SHA/environment
no PR head different from verified SHA
no stale approval accepted
no cross-worktree mutation
no unscoped persistent credential
no builder-only completion verdict
no retry beyond policy
no silent canonical-knowledge mutation
```
## 7. Failure taxonomy
Classify failures consistently:
```text
Intent
├── missed Signal
├── wrong Work
├── scope drift
└── unresolved ambiguity
Design
├── missed impact
├── wrong boundary
├── invalid slice
└── hidden trade-off
Execution
├── model failure
├── harness failure
├── sandbox failure
├── tool failure
├── timeout/cancellation
└── recovery/idempotency failure
Verification
├── missing check
├── flaky check
├── false pass
├── false failure
└── wrong revision/environment
Delivery
├── integration conflict
├── wrong PR revision
├── release failure
└── rollback failure
Outcome
├── user problem remains
├── regression
└── inconclusive observation
```
Every failed Work records one primary and optional contributing categories.
## 8. Experiment protocol
Any change to agent, prompt, model, Kit, tool, runtime policy, or Resolver heuristic requires:
```text
hypothesis
target metric
candidate version
baseline version
fixture set
budget
results
regressions
decision
```
Recommended sequence:
```text
offline corpus
→ shadow/dry run
→ small dogfood cohort
→ policy-controlled rollout
```
Do not promote based on one impressive example.
## 9. Agent/Kit promotion gate
A candidate may replace baseline only when:
- golden invariants pass;
- no critical regression;
- task success does not decrease materially;
- review/rework improves or remains acceptable;
- compute/time increase is justified;
- failure mode and rollback are known;
- version can be selected/reverted independently.
Record promotion as a durable decision.
## 10. Verifier evaluation
Verifiers themselves need evaluation.
Test:
- whether injected defects are caught;
- false-positive rate;
- whether checks bind exact candidate;
- whether design deviations are identified;
- whether evidence is concise and actionable;
- whether repair instructions reproduce the failure.
Use mutation/injected-defect suites where practical.
## 11. Human review calibration
Periodically compare:
```text
agent/verifier verdict
human reviewer verdict
post-merge outcome
```
Track disagreement categories. A reviewer model agreeing with another model is not ground truth.
## 12. Post-release feedback
For completed Work, schedule/perform observation using defined success signals.
Convert:
- incidents;
- support complaints;
- failed health checks;
- rollback;
- unexpected metrics;
- manual rework
into linked Signals and evaluation records.
## 13. Improvement outputs
Evaluation may produce proposals for:
```text
Work Definition rule
Design Packet template
slice heuristic
verification recipe
Kit/role/tool
runtime policy
repository knowledge
UI review package
```
Each proposal states evidence, expected benefit, risk, and rollback.
## 14. Dashboard minimum
Internal dogfood dashboard:
```text
active/stale Work
terminal outcome distribution
first-pass verification
retry/replan causes
human wait/attention
duplicate side effects
review changes requested
post-merge failures
cost/time per verified Work
agent/Kit version comparison
```
Do not collapse quality into one vanity score.
## 15. Initial benchmark gate
Before v0 dogfood, pass:
```text
✓ actionable vs casual Signal fixtures
✓ duplicate processing
✓ definition/design version invalidation
✓ fake Resolver crash recovery
✓ sandbox cancellation
✓ verification repair
✓ exact-SHA PR invariant
✓ human question/resume
✓ duplicate callback/PR prevention
```
Before increasing autonomy, add:
```text
✓ integration conflict
✓ auth/permission fixture
✓ migration/rollback fixture
✓ post-release regression fixture
```
## 16. Continuous improvement loop
```text
Run Work
→ capture structured outcome
→ classify failures
→ aggregate patterns
→ propose one bounded change
→ evaluate against corpus
→ dogfood
→ promote or revert
```
Improve the weakest stage, not the most fashionable model.

44
docs/glossary.md Normal file
View File

@@ -0,0 +1,44 @@
# Zopu Work OS — Canonical Glossary
> **Purpose:** prevent agents and humans from using overlapping terms inconsistently.
| Term | Definition | Not the same as |
|---|---|---|
| Project | Durable product/repository/context boundary. | Work or chat thread |
| Message | Exact conversational input/output. | Signal |
| Signal | Provenanced evidence that may require attention. | Work |
| Work | Durable desired outcome linking all evidence and lifecycle. | Issue, task, session, PR |
| Work Definition | Versioned product-level contract: problem, outcome, criteria, scope, risk. | Implementation plan |
| Impact Map | Systems/contracts/data/operations potentially affected. | File list only |
| Architecture | Component interaction, ownership, contracts, trust/data flow. | Program design |
| Design Packet | Versioned implementation intent: file/call-flow deltas, types, invariants, slices. | Generated code |
| Vertical Slice | Bounded increment exposing observable behavior across necessary layers. | Horizontal backend/frontend phase |
| Step | Executable graph node; a slice may contain implementation/verify/publish steps. | Work |
| Kit | Versioned executable policy/configuration for resolving Work. | Agent or runtime |
| Kit Builder | Compiler selecting/composing a Kit from approved intent and policy. | Unbounded prompt generator |
| Resolver | Durable engine deciding what must happen next until terminal outcome. | Coding harness |
| Run | Coordination record for resolving a Work/step using one Kit version. | Attempt |
| Attempt | One bounded execution in one runtime/harness context. | Whole Work |
| Actor | Rivet durable identity owning serialized state/lifecycle. | Agent/model |
| Agent | Program/model-directed reasoning role producing proposals or actions. | Actor |
| Harness | Coding/tool-loop implementation such as OMP/OpenCode/Codex/Pi. | Sandbox |
| Runtime | Capability provider executing processes/files/network. | Harness |
| Sandbox | Isolated runtime instance for untrusted/project execution. | Worktree |
| Orb | Product-level execution envelope: runtime, workspace, harness, context, credentials, artifacts. | Agent |
| Worktree | Git checkout isolated for a candidate branch/Work. | Sandbox |
| Candidate | Unverified implementation revision/output. | Verified result |
| Verification Plan | Predeclared checks and evidence required for completion. | Test command only |
| Verification Result | Evidence-bound verdict for an exact revision/environment. | Agent statement |
| Artifact | Durable output/reference: diff, report, screenshot, commit, PR, preview. | Evidence automatically |
| Evidence | Artifact/data supporting a specific claim/check. | Summary prose |
| Question | Structured human input request blocking or affecting Work. | Chat thread |
| Decision | Durable answer/approval bound to exact Work/design/version. | Informal message |
| Review Package | Human-oriented narrative of intent, design, changes, evidence, risks. | Raw Git diff |
| Integration | Composition and verification of multiple slice/candidate changes. | Merge without checks |
| Delivery | Publication/release artifacts and transitions. | Outcome |
| Result | Actual effect compared with desired outcome. | PR merged |
| Learning | Post-work observation proposing future knowledge/process improvement. | Automatic memory mutation |
| Knowledge | Reviewed durable project/product facts, decisions, and conventions. | Harness session memory |
| Policy | Deterministic constraints for risk, tools, permissions, gates, budgets. | Model preference |
| Provenance | Exact origin and chain linking Signal, decision, code, evidence, result. | Generated summary |
| Terminal state | Explicit successful/failed/blocked/cancelled outcome requiring no hidden work. | “Agent stopped” |

65
docs/manifest.json Normal file
View File

@@ -0,0 +1,65 @@
{
"read_order": [
"agent-context.md",
"product.md",
"glossary.md",
"dev-loop.md",
"tech.md",
"design.md",
"slices.md",
"dev-plan.md",
"evaluation.md"
],
"files": {
"README.md": {
"bytes": 1957,
"lines": 57,
"sha256": "9f732b8c40e1714767bbf31ef8ea6263c64b572775555f00527d775c6204f9c2"
},
"agent-context.md": {
"bytes": 7930,
"lines": 328,
"sha256": "6f2e97bc7e2606e84792391beb2d5bb8f4c23a720c277e4b22976cab2cfaf730"
},
"design.md": {
"bytes": 8470,
"lines": 469,
"sha256": "4d42edbcf6f723b2e845350b5b99925b78fc2fa09783d726f02fec5389bf0a9c"
},
"dev-loop.md": {
"bytes": 15737,
"lines": 705,
"sha256": "3fe99dcbf05cecfd2403b289fd9d0545372309e41c02973875739ace5822eb04"
},
"dev-plan.md": {
"bytes": 10138,
"lines": 445,
"sha256": "4e1e68fe91a9f442c1f662d133a27efb9c74223ed92f2f2634cff2aa36d8e429"
},
"evaluation.md": {
"bytes": 8310,
"lines": 405,
"sha256": "22037e62ae0b7abd1b639c3bd0b6e1a19328b919dd6a7c3fc4a1b7a62c9cac7e"
},
"glossary.md": {
"bytes": 3800,
"lines": 44,
"sha256": "79abd47b212d3b3c160238f703360a841090da6c9787b9c9f8871e14588aef74"
},
"product.md": {
"bytes": 10423,
"lines": 422,
"sha256": "020e1b7dd00be90aa1061f1ab2cd1a0416f48735c281a079b1e995dc333c5b75"
},
"slices.md": {
"bytes": 10132,
"lines": 493,
"sha256": "7ee7500685e5789f2aebf29d6a3b7d14866edc7c0f85b2f2c727edb669e66b00"
},
"tech.md": {
"bytes": 15349,
"lines": 663,
"sha256": "bcef9fdd0166ead4ff9f1c898016098871089680956de79e990e66d5d472e4d8"
}
}
}

493
docs/slices.md Normal file
View File

@@ -0,0 +1,493 @@
# Zopu Work OS — Vertical Slices
> **Related:** `dev-loop.md` defines the software-building process; `dev-plan.md` defines implementation sequencing.
> **Purpose:** ordered product increments. Complete each slice end-to-end before starting the next.
> **Reference task:** “Add `GET /health` returning status and current build commit.”
> **Rule:** every slice must produce visible product behavior, durable state, and automated acceptance coverage.
## Global definition of done
For every slice:
- schema/state migrations applied;
- commands/events are idempotent;
- actor restart does not corrupt state;
- frontend handles loading/error/empty states;
- important actions are audited;
- unit/integration tests cover invariants;
- no provider-specific types leak into domain;
- docs updated when contracts change.
## Slice 1 — Conversation → Signal → Work card
### User outcome
A user message creates one actionable Signal and one proposed Work card with exact provenance.
### Backend
```text
Message
Signal
Work
WorkEvent
ProcessMessage → structured FLUE proposal → validated command
```
Implement:
- persist exact message;
- Signal fingerprint/idempotency;
- create vs attach decision;
- proposed Work state;
- Work event feed.
### Frontend
- continuous chat;
- inline Work creation notice;
- collapsed reactive Work card;
- source-message link.
### Acceptance
- duplicate processing creates no duplicate Signal/Work;
- casual message creates no Work;
- actionable message creates one Work;
- card survives refresh/restart;
- exact source text is recoverable.
### Explicitly exclude
Planning, sandboxes, Git, verification.
---
## Slice 2 — Work Definition and approval
### User outcome
The proposed Work becomes a testable outcome contract that can be edited and approved.
### Backend
Add:
```text
WorkDefinition(versioned)
DefinitionApproval
Question
RiskClass
```
FLUE compiles:
- problem;
- desired outcome;
- scope/non-goals;
- acceptance criteria;
- assumptions/questions;
- risk.
Work state:
```text
Proposed → Defining → AwaitingDefinitionApproval → Designing
```
### Frontend
Expanded Outcome section; edit/approve/request revision; unresolved-question cards.
### Acceptance
- high-impact unresolved question blocks approval;
- approval binds exact version;
- revision invalidates stale approval;
- risk lane is visible;
- definition can be reconstructed from event history.
---
## Slice 3 — Design Packet and vertical slices
### User outcome
The user can review expected architecture/code shape and approve a bounded slice plan before coding.
### Backend
Add versioned:
```text
ImpactMap
DesignPacket
VerticalSlice
VerificationPlan
DesignApproval
```
Required Design Packet fields:
- affected systems/files;
- architecture summary;
- expected file-tree/call-flow changes;
- key types/invariants;
- risks/trade-offs;
- 14 vertical slices;
- evidence requirements per slice.
### Frontend
Design section with diagram, file tree, call flow, slices, version diff, approval.
### Acceptance
- horizontal “backend/frontend/test later” plan rejected;
- each slice is independently observable/verifiable;
- approval binds definition+design versions;
- changed definition invalidates dependent design;
- Work reaches `Ready`.
---
## Slice 4 — Resolver state machine with fake harness
### User outcome
Starting Work visibly advances slices, retries bounded failures, and surfaces blockers without real code execution.
### Backend
Add:
```text
Run
Attempt
ResolverDecision
KitVersion
HarnessRuntime port
FakeHarnessLive
```
Static `CodingKitV0`.
Implement durable loop:
```text
Ready → ExecutingSlice → VerifyingSlice → NextSlice
```
Attempt outcomes:
```text
Succeeded | RetryableFailure | NeedsInput | Blocked
| VerificationFailed | BudgetExhausted | Cancelled | PermanentFailure
```
### Frontend
Slice progress, meaningful activity, retry count, stop/retry controls, question state.
### Acceptance
- injected transient failure retries up to policy;
- restart resumes from durable state;
- no executable step produces explicit failure;
- cancellation terminates attempt;
- no Work remains permanently “running.”
---
## Slice 5 — Real sandbox + implementation harness
### User outcome
Zopu implements one approved slice in an isolated repository environment and streams useful progress.
### Backend
Initial adapter choice:
```text
SandboxRuntime = CubeSandboxLive
HarnessRuntime = OmpHarnessLive (or one chosen harness)
```
Flow:
```text
prepare worktree
→ create sandbox
→ clone/mount repo
→ inject context
→ run one slice
→ normalize events
→ collect diff/artifacts
→ pause/terminate
```
Security:
- scoped Git/model tokens;
- isolated HOME/worktree;
- one mutating attempt per worktree;
- timeout/cancel cleanup.
### Frontend
Current activity, changed files, artifact links, expandable raw logs.
### Acceptance
- real repository file changes occur;
- another Work cannot see/modify checkout;
- cancellation stops process;
- provider failure becomes classified attempt outcome;
- exact base/candidate revision recorded.
---
## Slice 6 — Independent verification and repair
### User outcome
The card shows objective evidence; failed checks trigger a bounded repair loop.
### Backend
Implement `VerificationRuntime` and verifier role.
Initial checks:
```text
format/lint/typecheck
focused tests
service start
HTTP behavior
secret scan
expected vs actual files/interfaces
test weakening/deletion detection
```
Bind result to candidate SHA/environment.
Repair:
```text
failure evidence → repair attempt → clean rerun
```
### Frontend
Evidence checklist, exact failures, candidate SHA, repair status.
### Acceptance
- implementer cannot self-mark passed;
- failed check stores output/exit code;
- repair receives exact evidence;
- max attempts enforced;
- final verdict is Passed/Failed/Inconclusive.
---
## Slice 7 — Git publication and review package
### User outcome
A verified candidate becomes a real branch/commit/PR with an understandable review package.
### Backend
`SourceControl` adapter:
```text
commit → push → create PR
```
Idempotent artifact creation; verify PR head SHA equals verified SHA.
Generate review package:
- intent/definition/design;
- slice narrative;
- important diffs;
- screenshots/API evidence;
- checks;
- deviations/risks.
### Frontend
Delivery section, PR action, narrative review package.
### Acceptance
- no duplicate PR after retry;
- exact verified SHA published;
- PR link survives restart;
- merge remains human-controlled;
- request-changes action returns Work to appropriate state.
**Milestone:** first useful product.
---
## Slice 8 — Contextual human intervention
### User outcome
A blocked agent asks one precise question; the user answers from the Work card; the same Work resumes.
### Backend
Durable `Question`/`Decision`; map response to Work/slice/attempt/harness session.
### Frontend
Attention card with recommendation, alternatives, consequences; contextual composer.
### Acceptance
- answer persists before resume;
- harness restart does not lose decision;
- stale questions cannot mutate newer plan;
- response never creates unrelated thread;
- attention queue orders blockers correctly.
---
## Slice 9 — Multi-slice integration verification
### User outcome
Several passing slices are combined and tested as one exact candidate before PR readiness.
### Backend
Add `IntegrationActor`/use case:
```text
compose commits
detect overlap/conflict
rebase/resolve policy
run impacted checks on integrated SHA
```
### Frontend
Integration state, conflict blocker, combined evidence.
### Acceptance
- individually passing slices cannot skip combined checks;
- conflicts become explicit blockers;
- integrated SHA is the published SHA;
- failed integration can replan/repair.
---
## Slice 10 — Preview, release, and observation
### User outcome
The user can inspect a preview, approve delivery, and see whether the released behavior works.
### Backend
Provider-neutral `PreviewRuntime`/release adapter. Add:
```text
RolloutPlan
RollbackPlan
HealthSignal
ObservationWindow
WorkResult
```
### Frontend
Preview link, release gate, health state, expected-vs-actual result.
### Acceptance
- preview binds candidate SHA;
- release requires policy-defined approval;
- observation records actual behavior;
- rollback trigger is explicit for critical work;
- merged PR alone does not mark outcome achieved.
---
## Slice 11 — Learning and knowledge proposals
### User outcome
Completed Work produces reviewable improvements to project knowledge and execution policy.
### Backend
Synthesize:
- planning errors;
- missing context;
- useful/failing tools;
- escaped defects;
- Kit/test recommendations.
Create proposal artifacts, never direct canonical mutation.
### Frontend
Diffable learning cards: accept/edit/reject.
### Acceptance
- every proposal cites run/evidence;
- rejection is retained;
- accepted update is versioned;
- no autonomous rewrite of canonical docs.
---
## Slice 12 — Dynamic Kit Builder and controlled fleet
### User outcome
Zopu selects/composes the right roles, tools, runtime, and checks for different software Work while preserving policy.
### Backend
Kit compiler inputs:
```text
Work Definition + risk + Design Packet + project policy
+ runtime/tool registry + previous results
```
Outputs immutable/versioned `ExecutionKit`.
Add roles only with measurable value:
```text
investigator implementer verifier security-reviewer
browser-tester integration-coordinator
```
### Acceptance
- Kit is explainable/versioned;
- tool grants are least privilege;
- budgets enforced;
- role addition has evaluation evidence;
- dynamic tools are proposed/reviewed before trust;
- fallback static Kit remains available.
## Dependency graph
```text
1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12
```
Parallel engineering is allowed *within* a slice after contracts land, but product release order remains sequential.

View File

@@ -3,8 +3,15 @@
"private": true, "private": true,
"workspaces": { "workspaces": {
"packages": [ "packages": [
"apps/*", "apps/web",
"packages/*" "packages/agents",
"packages/auth",
"packages/backend",
"packages/config",
"packages/env",
"packages/primitives",
"packages/ui",
"packages/work-os"
], ],
"catalog": { "catalog": {
"@rivet-dev/agentos": "^0.2.7", "@rivet-dev/agentos": "^0.2.7",
@@ -35,31 +42,25 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vp run -r dev", "dev": "vp run -r dev",
"build": "vp run -r build --filter '!desktop' && vp run --filter desktop build", "build": "vp run -r build",
"check-types": "vp run -r check-types", "check-types": "vp run -r check-types",
"check": "ultracite check", "check": "ultracite check package.json vite.config.ts apps/web/package.json apps/web/src/root.tsx apps/web/src/index.css apps/web/src/components/chat/chat-attachments.tsx apps/web/src/components/chat/chat-message.tsx apps/web/src/components/slice-one apps/web/src/hooks/chat/use-chat-agent.test.ts apps/web/src/hooks/chat/use-chat-images.ts apps/web/src/hooks/slice-one apps/web/src/lib/chat/attachments.ts apps/web/src/lib/chat/attachments.test.ts apps/web/src/lib/chat/transforms.ts apps/web/src/lib/chat/transforms.test.ts apps/web/src/lib/flue-transport.ts apps/web/src/lib/flue-transport.test.ts apps/web/src/lib/slice-one apps/web/src/routes/app/dashboard/page.tsx apps/web/src/routes/app/mobile/chat/page.tsx apps/web/src/routes/app/mobile/page.tsx packages/agents/package.json packages/agents/src/agents/zopu.ts packages/agents/src/tools/slice-one.ts packages/backend/package.json packages/backend/convex/schema.ts packages/backend/convex/works.ts packages/backend/convex/works.test.ts packages/work-os",
"lint": "vp lint", "lint": "vp lint",
"format": "vp fmt", "format": "vp fmt",
"smoke:zopu": "bun scripts/zopu-smoke.ts", "smoke:zopu": "bun scripts/zopu-smoke.ts",
"staged": "vp staged", "staged": "vp staged",
"hooks:setup": "vp config", "hooks:setup": "vp config",
"dev:native": "vp run --filter native dev",
"dev:web": "vp run --filter web dev", "dev:web": "vp run --filter web dev",
"dev:agents": "vp run --filter @code/agents dev", "dev:agents": "vp run --filter @code/agents dev",
"dev:tailscale:web": "vp run --filter web dev:tailscale", "dev:tailscale:web": "vp run --filter web dev:tailscale",
"dev:tailscale:agents": "vp run --filter @code/agents dev:tailscale", "dev:tailscale:agents": "vp run --filter @code/agents dev:tailscale",
"dev:desktop": "vp run --filter desktop dev:hmr",
"build:desktop": "vp run --filter desktop build:stable",
"build:desktop:canary": "vp run --filter desktop build:canary",
"dev:tui": "vp run --filter tui dev",
"dev:server": "vp run --filter @code/backend dev", "dev:server": "vp run --filter @code/backend dev",
"dev:setup": "vp run --filter @code/backend dev:setup", "dev:setup": "vp run --filter @code/backend dev:setup",
"dev:daemon": "vp run --filter daemon dev",
"build:daemon": "vp run --filter daemon build",
"build:agents": "vp run --filter @code/agents build", "build:agents": "vp run --filter @code/agents build",
"docs:update": "bun run scripts/update-docs.ts", "docs:update": "bun run scripts/update-docs.ts",
"subtree": "bun run scripts/subtree.ts", "subtree": "bun run scripts/subtree.ts",
"fix": "ultracite fix", "fix": "ultracite fix",
"slice1": "vp run -r dev",
"orb:proof": "bun run scripts/orb-proof.ts", "orb:proof": "bun run scripts/orb-proof.ts",
"orb:run": "bun run scripts/orb-project-run.ts", "orb:run": "bun run scripts/orb-project-run.ts",
"dev:zopu": "vp run --filter @code/server dev", "dev:zopu": "vp run --filter @code/server dev",
@@ -71,6 +72,7 @@
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@code/primitives": "workspace:*", "@code/primitives": "workspace:*",
"@code/work-os": "workspace:*",
"@flue/sdk": "1.0.0-beta.9", "@flue/sdk": "1.0.0-beta.9",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "^22.13.14", "@types/node": "^22.13.14",

View File

@@ -17,6 +17,7 @@
"@code/backend": "workspace:*", "@code/backend": "workspace:*",
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@code/primitives": "workspace:*", "@code/primitives": "workspace:*",
"@code/work-os": "workspace:*",
"@flue/runtime": "latest", "@flue/runtime": "latest",
"@rivet-dev/agentos-core": "catalog:", "@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:", "convex": "catalog:",

View File

@@ -1,3 +1,6 @@
import { exec, execFile } from "node:child_process";
import { promisify } from "node:util";
import { api } from "@code/backend/convex/_generated/api"; import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel"; import type { Id } from "@code/backend/convex/_generated/dataModel";
import { parseAgentEnv } from "@code/env/agent"; import { parseAgentEnv } from "@code/env/agent";
@@ -9,6 +12,64 @@ import {
createGiteaHttpTransport, createGiteaHttpTransport,
runPostRunGiteaLifecycle, runPostRunGiteaLifecycle,
} from "../git/gitea"; } from "../git/gitea";
import { AgentOsSandboxApi } from "../sandboxes/agent-os";
import {
clonePublicRepository,
mirrorSandboxToHostCheckout,
} from "../sandboxes/host-repository-bridge";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const GIT_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024;
const createHostGitRunner = () => ({
async run(
command: string,
options?: { cwd?: string; env?: Record<string, string> }
) {
try {
const result = await execAsync(command, {
cwd: options?.cwd,
env: options?.env,
maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
});
return {
exitCode: 0,
stderr: String(result.stderr),
stdout: String(result.stdout),
};
} catch (error) {
const failure = error as Error & {
readonly code?: number;
readonly stderr?: string;
readonly stdout?: string;
};
return {
exitCode:
typeof failure.code === "number" && failure.code > 0
? failure.code
: 1,
stderr: failure.stderr ?? failure.message,
stdout: failure.stdout ?? "",
};
}
},
});
const makeAuthenticatedRepositoryUrl = (
repositoryUrl: string,
repositoryPath: string,
token: string
): string => {
const [owner] = repositoryPath.split("/");
if (!owner) {
throw new Error("Gitea repository path has no owner");
}
const url = new URL(repositoryUrl);
url.username = owner;
url.password = token;
return url.toString();
};
const pullRequestOutput = v.object({ const pullRequestOutput = v.object({
baseBranch: v.string(), baseBranch: v.string(),
@@ -49,7 +110,7 @@ export const createFinalizeGiteaLifecycle = (
}), }),
name: "finalize_gitea_lifecycle", name: "finalize_gitea_lifecycle",
output, output,
async run({ harness, input, log }) { async run({ input, log }) {
const context = await client.query(api.agentWorkspace.get, { const context = await client.query(api.agentWorkspace.get, {
issueId, issueId,
token: env.FLUE_DB_TOKEN, token: env.FLUE_DB_TOKEN,
@@ -65,21 +126,49 @@ export const createFinalizeGiteaLifecycle = (
"GITEA_TOKEN is required for Gitea pull request creation" "GITEA_TOKEN is required for Gitea pull request creation"
); );
} }
if (!context.run) {
throw new Error("Project work run is not initialized");
}
const runner = { const sandbox = new AgentOsSandboxApi(
async run( client,
command: string, env.DAEMON_ID,
options?: { cwd?: string; env?: Record<string, string> } context.run.actorKey
) { );
return await harness.shell(command, options); const checkout = await clonePublicRepository({
}, branchName:
}; context.run.branchName ?? `work/issue-${context.issue.number}`,
repositoryUrl: context.source.url,
});
const runner = createHostGitRunner();
const transport = createGiteaHttpTransport({ const transport = createGiteaHttpTransport({
baseUrl: env.GITEA_URL, baseUrl: env.GITEA_URL,
token: env.GITEA_TOKEN, token: env.GITEA_TOKEN,
}); });
try { try {
await mirrorSandboxToHostCheckout({
checkoutDirectory: checkout.checkoutDirectory,
sandbox,
sandboxDirectory: context.run.checkoutPath ?? "/workspace/repository",
});
await execFileAsync(
"git",
[
"remote",
"set-url",
"origin",
makeAuthenticatedRepositoryUrl(
context.source.url,
context.source.repositoryPath,
env.GITEA_TOKEN
),
],
{
cwd: checkout.checkoutDirectory,
maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
}
);
const result = await runPostRunGiteaLifecycle({ const result = await runPostRunGiteaLifecycle({
baseBranch: context.source.defaultBranch, baseBranch: context.source.defaultBranch,
body: `Verified changes for project issue #${context.issue.number}. Merge remains a manual review action.`, body: `Verified changes for project issue #${context.issue.number}. Merge remains a manual review action.`,
@@ -91,7 +180,7 @@ export const createFinalizeGiteaLifecycle = (
title: `Issue #${context.issue.number}: ${context.issue.title}`, title: `Issue #${context.issue.number}: ${context.issue.title}`,
transport, transport,
verification: input.verified ? "passed" : "failed", verification: input.verified ? "passed" : "failed",
workspace: "/workspace/repository", workspace: checkout.checkoutDirectory,
}); });
await client.mutation(api.agentWorkspace.recordGiteaLifecycle, { await client.mutation(api.agentWorkspace.recordGiteaLifecycle, {
baseBranch: result.baseBranch, baseBranch: result.baseBranch,
@@ -113,7 +202,7 @@ export const createFinalizeGiteaLifecycle = (
return result; return result;
} catch (error) { } catch (error) {
const branchResult = await runner.run("git branch --show-current", { const branchResult = await runner.run("git branch --show-current", {
cwd: "/workspace/repository", cwd: checkout.checkoutDirectory,
}); });
const branch = branchResult.stdout.trim() || "unknown"; const branch = branchResult.stdout.trim() || "unknown";
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
@@ -126,6 +215,8 @@ export const createFinalizeGiteaLifecycle = (
token: env.FLUE_DB_TOKEN, token: env.FLUE_DB_TOKEN,
}); });
throw error; throw error;
} finally {
await checkout.cleanup();
} }
}, },
}); });

View File

@@ -22,7 +22,7 @@ export default defineAgent(({ env, id }) => {
Start every run by calling lookup_issue_context, reading /workspace/control/issue.md, the canonical context files under /workspace/control/context, and the operational artifacts under /workspace/control/artifacts. Call report_work_status with working before editing. Start every run by calling lookup_issue_context, reading /workspace/control/issue.md, the canonical context files under /workspace/control/context, and the operational artifacts under /workspace/control/artifacts. Call report_work_status with working before editing.
Work only on the bound issue. The repository checkout is the current working directory; inspect existing source files before changing them. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant install, edit, test, or scenario command in AgentOS, and preserve command evidence. Work only on the bound issue. The repository checkout is the current working directory; inspect existing source files before changing them. Git metadata stays on the trusted host bridge, so do not run git commands inside AgentOS—the final lifecycle action mirrors verified files into the host checkout before committing and pushing. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant install, edit, test, or scenario command in AgentOS, and preserve command evidence.
Before finishing, update the operational files under /workspace/control/artifacts and publish each changed canonical artifact with publish_project_artifact. After verification, call finalize_gitea_lifecycle with verified=true; it owns the post-run Git/Gitea lifecycle and never merges. Report completed only after that action succeeds or reports no_changes. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`, Before finishing, update the operational files under /workspace/control/artifacts and publish each changed canonical artifact with publish_project_artifact. After verification, call finalize_gitea_lifecycle with verified=true; it owns the post-run Git/Gitea lifecycle and never merges. Report completed only after that action succeeds or reports no_changes. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`, model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,

View File

@@ -1,22 +1,13 @@
import path from "node:path";
import { parseAgentEnv } from "@code/env/agent"; import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime"; import { defineAgent } from "@flue/runtime";
// import { agentos } from "../sandboxes/agentos"; import { createSliceOneTools } from "../tools/slice-one";
import { local } from "@flue/runtime/node";
import paseo from "../skills/paseo/SKILL.md" with { type: "skill" }; const INSTRUCTIONS = `You are Zopu for product Slice 1: Conversation to Signal to proposed Work.
import { paseoCli } from "../tools/paseo";
import { createSignalRoutingTools } from "../tools/signals";
const repositoryRoot = path.resolve(process.cwd(), "../..");
const INSTRUCTIONS = `You are Zopu, the global planning and work-routing agent for the Zopu Work OS.
## Your role ## Your role
You listen to user messages in the persistent global conversation and decide whether they contain actionable work. The control plane stores every user message as exact evidence before you process it; you never supply or rewrite raw message text. The application stores each user message as exact evidence before you process it. You may interpret that evidence, but never supply, rewrite, or invent source text.
## Work routing loop ## Work routing loop
@@ -24,7 +15,9 @@ When a user sends a message, follow this decision flow:
1. **Assess actionability.** Does the message contain a concrete problem, request, blocker, opportunity, or decision that warrants a work unit? Greetings, questions about the system, casual conversation, and exploration do NOT create work. If the message is casual conversation, respond naturally and do nothing else. 1. **Assess actionability.** Does the message contain a concrete problem, request, blocker, opportunity, or decision that warrants a work unit? Greetings, questions about the system, casual conversation, and exploration do NOT create work. If the message is casual conversation, respond naturally and do nothing else.
2. **Identify project context.** If the message references a project, call list_projects to find it. If the user has one project, use it. If the project is ambiguous, ask one focused clarification. Direct questions, casual conversation, and image-reading requests must be answered immediately without calling any tools.
2. **Identify project context.** Call list_projects. If there is one project, use it. If there are several and the request is ambiguous, ask one focused question.
3. **Create a Signal (only when actionable).** When the message is actionable: 3. **Create a Signal (only when actionable).** When the message is actionable:
a. Call list_signal_evidence to see the exact admitted user messages. a. Call list_signal_evidence to see the exact admitted user messages.
@@ -32,46 +25,43 @@ When a user sends a message, follow this decision flow:
c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention. c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention.
d. Include the projectId when the project is known. d. Include the projectId when the project is known.
4. **Route the Signal.** After creating the Signal (or if one already exists): 4. **Route the Signal.** After creating the Signal:
a. Call list_active_issues for the relevant project. a. Call list_proposed_work for the project.
b. Compare the Signal's problem to existing issues. b. If the Signal clearly describes the same desired outcome as existing Work, call attach_signal_to_work.
c. If the Signal clearly relates to an existing active issue, call attach_signal_to_issue. c. Otherwise call create_work_from_signal.
d. If the Signal is new work that does not match any existing issue, call create_issue_from_signal.
e. If genuinely uncertain whether to attach or create, ask one focused question. e. If genuinely uncertain whether to attach or create, ask one focused question.
5. **Explain the outcome.** Tell the user clearly what happened: 5. **Explain the outcome.** Tell the user clearly what happened:
- "Created a Signal: [title] and attached it to issue #[number]: [issue title]." - "Captured [Signal title] and linked it to [Work title]."
- "Created a Signal: [title] and opened new issue #[number]: [issue title]." - "Captured [Signal title] and proposed [Work title]."
- Include the problem statement title so the user can verify accuracy. - Keep the response brief; the product renders the durable Work card separately.
6. **Optionally begin work.** Only when the user explicitly asks to start or work on the issue, call begin_issue. Do not begin work automatically.
## Rules ## Rules
- Never supply or rewrite the raw source message text. The control plane copies it server-side. - Never supply or rewrite the raw source message text. The control plane copies it server-side.
- Never create work from casual chat. - You receive and can see images attached to the current user message. This model supports image input. Never claim images are unavailable, omitted, or unsupported. If a message has attached images, inspect them before responding.
- Never create Work from casual chat.
- Ask at most one focused clarification when genuinely ambiguous. - Ask at most one focused clarification when genuinely ambiguous.
- Preserve project and organization scope at all times. - Preserve project and organization scope at all times.
- Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent. - Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent.
- Never claim you created a Signal until the tool call returns successfully. - Never claim you created a Signal until the tool call returns successfully.
- Do not create Candidate Work, Work Units, or Runs directly. Those are downstream concerns. - Do not start implementation, planning, sandboxes, Git, verification, or delivery. Those are explicitly outside Slice 1.
- When the user asks about existing work, use list_active_issues and list_recent_signals to answer.`; - Proposed Work is the only Work status in this slice.`;
export { authenticatedAgentRoute as route } from "../auth"; export {
authenticatedAgentRoute as attachments,
authenticatedAgentRoute as route,
} from "../auth";
export default defineAgent(({ env, id }) => { export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env); const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return { return {
cwd: repositoryRoot,
description: description:
"Project-scoped work-routing agent that turns conversation into Signals and routes them to issues.", "Turns actionable conversation into provenanced Signals and proposed Work.",
instructions: INSTRUCTIONS, instructions: INSTRUCTIONS,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`, model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
// sandbox: agentos(), thinkingLevel: "medium",
sandbox: local({ cwd: repositoryRoot }), tools: createSliceOneTools(id, env),
skills: [paseo],
tools: [paseoCli, ...createSignalRoutingTools(id, env)],
}; };
}); });

View File

@@ -9,18 +9,25 @@ const agentEnv = parseAgentEnv(process.env);
registerProvider(agentEnv.AGENT_MODEL_PROVIDER, { registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
api: agentEnv.AGENT_MODEL_API, api: agentEnv.AGENT_MODEL_API,
apiKey: agentEnv.AGENT_MODEL_API_KEY, apiKey:
agentEnv.AGENT_MODEL_PROVIDER === "openrouter"
? (agentEnv.OPENROUTER_API_KEY ?? agentEnv.AGENT_MODEL_API_KEY)
: agentEnv.AGENT_MODEL_API_KEY,
baseUrl: agentEnv.AGENT_MODEL_BASE_URL, baseUrl: agentEnv.AGENT_MODEL_BASE_URL,
models: { models: {
[agentEnv.AGENT_MODEL_NAME]: { [agentEnv.AGENT_MODEL_NAME]: {
contextWindow: agentEnv.AGENT_MODEL_CONTEXT_WINDOW, contextWindow: agentEnv.AGENT_MODEL_CONTEXT_WINDOW,
maxTokens: agentEnv.AGENT_MODEL_MAX_TOKENS, maxTokens: agentEnv.AGENT_MODEL_MAX_TOKENS,
}, },
"openai/gpt-oss-20b:free": {
contextWindow: 131_072,
maxTokens: agentEnv.AGENT_MODEL_MAX_TOKENS,
},
}, },
}); });
const app = new Hono(); const app = new Hono();
app.post("/project-requests", projectRequestRoute); app.post("/project-requests", projectRequestRoute);
app.route("/", flue()); app.route("/", flue() as unknown as Hono);
export default app; export default app;

View File

@@ -317,4 +317,5 @@ export const authenticatedAgentRoute: AgentRouteHandler = async (c, next) => {
clientRequestId, clientRequestId,
organizationId: organization._id, organizationId: organization._id,
}); });
return c.res;
}; };

View File

@@ -105,7 +105,7 @@ describe("Gitea lifecycle adapter", () => {
"git add --all && git commit -m 'feat(issue-5): Publish verified changes'", "git add --all && git commit -m 'feat(issue-5): Publish verified changes'",
"git rev-parse HEAD", "git rev-parse HEAD",
"git status --porcelain=v1", "git status --porcelain=v1",
"git push 'ssh://git@git.openputer.com:2222/puter/zopu-code.git' HEAD:'work/issue-5'", "git push origin HEAD:'work/issue-5'",
]); ]);
expect(pullRequests).toHaveLength(1); expect(pullRequests).toHaveLength(1);
}); });

View File

@@ -248,7 +248,7 @@ export const runPostRunGiteaLifecycle = async (
}; };
} }
const repository = await input.transport.getRepository(input.repositoryPath); await input.transport.getRepository(input.repositoryPath);
await runChecked( await runChecked(
input.runner, input.runner,
@@ -276,7 +276,7 @@ export const runPostRunGiteaLifecycle = async (
await runChecked( await runChecked(
input.runner, input.runner,
`git push ${shellQuote(repository.sshUrl)} HEAD:${shellQuote(inspection.branch)}`, `git push origin HEAD:${shellQuote(inspection.branch)}`,
{ cwd: input.workspace } { cwd: input.workspace }
); );

View File

@@ -11,6 +11,11 @@ import { ConvexHttpClient } from "convex/browser";
import { Effect } from "effect"; import { Effect } from "effect";
import * as v from "valibot"; import * as v from "valibot";
import {
clonePublicRepository,
mirrorHostCheckoutToSandbox,
} from "./host-repository-bridge";
const execResultSchema = v.object({ const execResultSchema = v.object({
exitCode: v.number(), exitCode: v.number(),
stderr: v.string(), stderr: v.string(),
@@ -29,7 +34,7 @@ interface ExecuteOptions {
readonly timeoutMs?: number; readonly timeoutMs?: number;
} }
class AgentOsSandboxApi implements SandboxApi { export class AgentOsSandboxApi implements SandboxApi {
readonly #actorKey: string[]; readonly #actorKey: string[];
readonly #client: ConvexHttpClient; readonly #client: ConvexHttpClient;
readonly #daemonId: string; readonly #daemonId: string;
@@ -258,13 +263,27 @@ export const agentOs = (
if (!checkoutOperation || checkoutOperation._tag !== "Exec") { if (!checkoutOperation || checkoutOperation._tag !== "Exec") {
throw new Error("Issue workspace plan must include a checkout command"); throw new Error("Issue workspace plan must include a checkout command");
} }
const checkoutCommandResult = await sandbox.exec( const hostCheckout = await clonePublicRepository({
checkoutOperation.command, branchName: plan.branchName,
{ repositoryUrl: plan.sourceUrl,
cwd: checkoutOperation.cwd,
timeoutMs: checkoutOperation.timeoutMs, timeoutMs: checkoutOperation.timeoutMs,
});
try {
if (!(await sandbox.exists(plan.checkoutPath))) {
await mirrorHostCheckoutToSandbox({
checkoutDirectory: hostCheckout.checkoutDirectory,
sandbox,
sandboxDirectory: plan.checkoutPath,
});
} }
); } finally {
await hostCheckout.cleanup();
}
const checkoutCommandResult = {
exitCode: 0,
stderr: "",
stdout: `${plan.branchName} ${hostCheckout.headSha}\n`,
};
await Promise.all( await Promise.all(
stagingOperations stagingOperations

View File

@@ -0,0 +1,297 @@
import {
lstat,
mkdir,
mkdtemp,
readFile,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import nodePath from "node:path";
import type { FileStat, SandboxApi } from "@flue/runtime";
import { afterEach, describe, expect, it } from "vitest";
import {
mirrorHostCheckoutToSandbox,
mirrorSandboxToHostCheckout,
parseGitCommitSha,
parsePublicGitUrl,
} from "./host-repository-bridge";
class FakeSandbox implements SandboxApi {
readonly files = new Map<string, Uint8Array>();
readonly directories = new Set<string>(["/"]);
async readFile(path: string): Promise<string> {
return new TextDecoder().decode(await this.readFileBuffer(path));
}
readFileBuffer(path: string): Promise<Uint8Array> {
const content = this.files.get(path);
if (!content) {
throw new Error(`Missing fake sandbox file: ${path}`);
}
return Promise.resolve(Uint8Array.from(content));
}
writeFile(path: string, content: string | Uint8Array): Promise<void> {
this.files.set(
path,
typeof content === "string"
? new TextEncoder().encode(content)
: Uint8Array.from(content)
);
return Promise.resolve();
}
stat(path: string): Promise<FileStat> {
const content = this.files.get(path);
if (content) {
return Promise.resolve({
isDirectory: false,
isFile: true,
size: content.byteLength,
});
}
if (this.directories.has(path)) {
return Promise.resolve({ isDirectory: true, isFile: false });
}
throw new Error(`Missing fake sandbox path: ${path}`);
}
readdir(path: string): Promise<string[]> {
const prefix = path === "/" ? "/" : `${path}/`;
const entries = new Set<string>();
for (const directory of this.directories) {
if (directory.startsWith(prefix)) {
const [entry] = directory.slice(prefix.length).split("/");
if (entry) {
entries.add(entry);
}
}
}
for (const file of this.files.keys()) {
if (file.startsWith(prefix)) {
const [entry] = file.slice(prefix.length).split("/");
if (entry) {
entries.add(entry);
}
}
}
return Promise.resolve([...entries]);
}
exists(path: string): Promise<boolean> {
return Promise.resolve(this.files.has(path) || this.directories.has(path));
}
mkdir(
path: string,
options?: { readonly recursive?: boolean }
): Promise<void> {
if (!options?.recursive) {
this.directories.add(path);
return Promise.resolve();
}
const parts = path.split("/").filter(Boolean);
let current = "";
for (const part of parts) {
current += `/${part}`;
this.directories.add(current);
}
return Promise.resolve();
}
rm(
path: string,
options?: { readonly force?: boolean; readonly recursive?: boolean }
): Promise<void> {
const prefix = `${path}/`;
if (options?.recursive) {
for (const file of this.files.keys()) {
if (file === path || file.startsWith(prefix)) {
this.files.delete(file);
}
}
for (const directory of this.directories) {
if (directory === path || directory.startsWith(prefix)) {
this.directories.delete(directory);
}
}
return Promise.resolve();
}
this.files.delete(path);
this.directories.delete(path);
return Promise.resolve();
}
exec(): Promise<{
exitCode: number;
stderr: string;
stdout: string;
}> {
void this.directories;
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
}
}
const temporaryDirectories: string[] = [];
const makeCheckout = async (): Promise<string> => {
const directory = await mkdtemp(nodePath.join(tmpdir(), "host-bridge-test-"));
temporaryDirectories.push(directory);
await mkdir(nodePath.join(directory, ".git"), { recursive: true });
await writeFile(nodePath.join(directory, ".git", "keep"), "git metadata");
return directory;
};
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true }))
);
});
describe("parsePublicGitUrl", () => {
it("accepts public HTTP(S) repositories", () => {
expect(
parsePublicGitUrl("https://git.openputer.com/team/repository.git")
.hostname
).toBe("git.openputer.com");
});
it.each([
"file:///tmp/repository",
"ssh://git@example.com/team/repository.git",
"https://token@example.com/team/repository.git",
"http://127.0.0.1/repository.git",
"http://192.168.1.10/repository.git",
])("rejects non-public repository URL %s", (repositoryUrl) => {
expect(() => parsePublicGitUrl(repositoryUrl)).toThrow();
});
});
describe("parseGitCommitSha", () => {
it("normalizes a full SHA returned by git", () => {
const sha = "a".repeat(40);
expect(parseGitCommitSha(` ${sha}\n`)).toBe(sha);
});
it.each(["", "abc123", "g".repeat(40), "a".repeat(41)])(
"rejects invalid commit SHA %s",
(sha) => {
expect(() => parseGitCommitSha(sha)).toThrow("invalid HEAD commit SHA");
}
);
});
describe("repository mirroring", () => {
it("copies host files into a sandbox without copying .git", async () => {
const checkoutDirectory = await makeCheckout();
await mkdir(nodePath.join(checkoutDirectory, "src"), {
recursive: true,
});
await writeFile(nodePath.join(checkoutDirectory, "README.md"), "hello");
await writeFile(
nodePath.join(checkoutDirectory, "src", "binary.bin"),
Uint8Array.from([0, 255, 1])
);
const sandbox = new FakeSandbox();
await mirrorHostCheckoutToSandbox({
checkoutDirectory,
sandbox,
sandboxDirectory: "/workspace/repository",
});
expect(
new TextDecoder().decode(
sandbox.files.get("/workspace/repository/README.md")
)
).toBe("hello");
expect(sandbox.files.get("/workspace/repository/src/binary.bin")).toEqual(
Uint8Array.from([0, 255, 1])
);
expect(
[...sandbox.files.keys()].some((filePath) => filePath.includes("/.git/"))
).toBe(false);
});
it("stages sandbox files, removes stale files, and preserves .git", async () => {
const checkoutDirectory = await makeCheckout();
await writeFile(nodePath.join(checkoutDirectory, "stale.txt"), "delete me");
const executablePath = nodePath.join(checkoutDirectory, "run.sh");
await writeFile(executablePath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
const sandbox = new FakeSandbox();
await sandbox.mkdir("/workspace/repository/src", { recursive: true });
await sandbox.writeFile(
"/workspace/repository/run.sh",
"#!/bin/sh\necho changed\n"
);
await sandbox.writeFile(
"/workspace/repository/src/index.ts",
"export const value = 1;\n"
);
await mirrorSandboxToHostCheckout({
checkoutDirectory,
sandbox,
sandboxDirectory: "/workspace/repository",
});
await expect(
readFile(nodePath.join(checkoutDirectory, "stale.txt"))
).rejects.toThrow();
await expect(
readFile(nodePath.join(checkoutDirectory, ".git", "keep"), "utf-8")
).resolves.toBe("git metadata");
await expect(
readFile(nodePath.join(checkoutDirectory, "src", "index.ts"), "utf-8")
).resolves.toBe("export const value = 1;\n");
const executableMetadata = await stat(executablePath);
expect(executableMetadata.mode % 0o1000).toBe(0o755);
});
it("enforces transfer limits before modifying the sandbox", async () => {
const checkoutDirectory = await makeCheckout();
await writeFile(nodePath.join(checkoutDirectory, "one.txt"), "1");
await writeFile(nodePath.join(checkoutDirectory, "two.txt"), "2");
const sandbox = new FakeSandbox();
await expect(
mirrorHostCheckoutToSandbox({
checkoutDirectory,
limits: { maxFiles: 1 },
sandbox,
sandboxDirectory: "/workspace/repository",
})
).rejects.toThrow("limit is 1");
expect(sandbox.files.size).toBe(0);
});
it("rejects host symbolic links", async () => {
const checkoutDirectory = await makeCheckout();
await writeFile(nodePath.join(checkoutDirectory, "outside.txt"), "outside");
await symlink(
"outside.txt",
nodePath.join(checkoutDirectory, "linked.txt")
);
const sandbox = new FakeSandbox();
await expect(
mirrorHostCheckoutToSandbox({
checkoutDirectory,
sandbox,
sandboxDirectory: "/workspace/repository",
})
).rejects.toThrow("Symbolic links are not supported");
const linkedMetadata = await lstat(
nodePath.join(checkoutDirectory, "linked.txt")
);
expect(linkedMetadata.isSymbolicLink()).toBe(true);
});
});

View File

@@ -0,0 +1,506 @@
import { execFile } from "node:child_process";
import {
chmod,
lstat,
mkdir,
mkdtemp,
readdir,
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { isIP } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import type { SandboxApi } from "@flue/runtime";
const DEFAULT_GIT_TIMEOUT_MS = 60_000;
const GIT_OUTPUT_LIMIT_BYTES = 1_048_576;
export interface RepositoryMirrorLimits {
readonly maxDepth: number;
readonly maxFileBytes: number;
readonly maxFiles: number;
readonly maxTotalBytes: number;
}
export const DEFAULT_REPOSITORY_MIRROR_LIMITS: RepositoryMirrorLimits = {
maxDepth: 64,
maxFileBytes: 10 * 1024 * 1024,
maxFiles: 10_000,
maxTotalBytes: 100 * 1024 * 1024,
};
export interface HostRepositoryCheckout {
readonly branchName: string;
readonly checkoutDirectory: string;
readonly headSha: string;
readonly temporaryDirectory: string;
readonly cleanup: () => Promise<void>;
}
interface MirrorFile {
readonly mode?: number;
readonly path: string;
readonly size: number;
}
interface GitResult {
readonly stderr: string;
readonly stdout: string;
}
const gitError = (
args: readonly string[],
error: Error & { readonly stderr?: string }
): Error => {
const detail = error.stderr?.trim() || error.message;
return new Error(`git ${args.join(" ")} failed: ${detail}`, {
cause: error,
});
};
const execFileAsync = promisify(execFile);
const runGit = async (
args: readonly string[],
options: { readonly cwd?: string; readonly timeoutMs: number }
): Promise<GitResult> => {
try {
const result = await execFileAsync("git", [...args], {
cwd: options.cwd,
maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
timeout: options.timeoutMs,
});
return {
stderr: String(result.stderr),
stdout: String(result.stdout),
};
} catch (error) {
if (!(error instanceof Error)) {
throw error;
}
throw gitError(
args,
error as Error & {
readonly stderr?: string;
}
);
}
};
const isPrivateIpv4 = (hostname: string): boolean => {
const octets = hostname.split(".").map(Number);
const [first = -1, second = -1] = octets;
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
);
};
const isPrivateIpv6 = (hostname: string): boolean => {
const normalized = hostname
.toLowerCase()
.replaceAll("[", "")
.replaceAll("]", "");
return (
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe8") ||
normalized.startsWith("fe9") ||
normalized.startsWith("fea") ||
normalized.startsWith("feb")
);
};
export const parsePublicGitUrl = (repositoryUrl: string): URL => {
const url = new URL(repositoryUrl);
if (url.protocol !== "https:" && url.protocol !== "http:") {
throw new Error("Public Git repositories must use an HTTP(S) URL");
}
if (url.username || url.password) {
throw new Error("Public Git repository URLs must not contain credentials");
}
const hostname = url.hostname.toLowerCase();
const addressKind = isIP(hostname);
const isPrivateAddress =
(addressKind === 4 && isPrivateIpv4(hostname)) ||
(addressKind === 6 && isPrivateIpv6(hostname));
if (
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local") ||
isPrivateAddress
) {
throw new Error("Public Git repository URLs must use a public host");
}
return url;
};
export const parseGitCommitSha = (value: string): string => {
const sha = value.trim();
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(sha)) {
throw new Error("Git returned an invalid HEAD commit SHA");
}
return sha;
};
export const clonePublicRepository = async (options: {
readonly branchName: string;
readonly repositoryUrl: string;
readonly timeoutMs?: number;
}): Promise<HostRepositoryCheckout> => {
const timeoutMs = options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
throw new Error("Git timeout must be a positive integer");
}
const repositoryUrl = parsePublicGitUrl(options.repositoryUrl).toString();
await runGit(["check-ref-format", "--branch", options.branchName], {
timeoutMs,
});
const temporaryDirectory = await mkdtemp(
path.join(tmpdir(), "zopu-host-repository-")
);
const checkoutDirectory = path.join(temporaryDirectory, "repository");
let headSha = "";
try {
await runGit(
[
"clone",
"--depth",
"1",
"--single-branch",
"--no-tags",
"--",
repositoryUrl,
checkoutDirectory,
],
{ timeoutMs }
);
await runGit(["switch", "-c", options.branchName], {
cwd: checkoutDirectory,
timeoutMs,
});
const headResult = await runGit(["rev-parse", "HEAD"], {
cwd: checkoutDirectory,
timeoutMs,
});
headSha = parseGitCommitSha(headResult.stdout);
} catch (error) {
await rm(temporaryDirectory, { force: true, recursive: true });
throw error;
}
return {
branchName: options.branchName,
checkoutDirectory,
cleanup: () =>
rm(temporaryDirectory, {
force: true,
recursive: true,
}),
headSha,
temporaryDirectory,
};
};
const resolveLimits = (
limits: Partial<RepositoryMirrorLimits> | undefined
): RepositoryMirrorLimits => {
const resolvedLimits = {
...DEFAULT_REPOSITORY_MIRROR_LIMITS,
...limits,
};
for (const [name, value] of Object.entries(resolvedLimits)) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
}
return resolvedLimits;
};
const assertSafeEntryName = (name: string): void => {
if (
!name ||
name === "." ||
name === ".." ||
name.includes("/") ||
name.includes("\\")
) {
throw new Error(`Sandbox returned an unsafe directory entry: ${name}`);
}
};
const assertWithinCheckout = (checkoutDirectory: string): string => {
const absoluteCheckout = path.resolve(checkoutDirectory);
if (absoluteCheckout === path.resolve(path.sep)) {
throw new Error("Repository checkout cannot be the filesystem root");
}
return absoluteCheckout;
};
const assertAbsoluteSandboxDirectory = (sandboxDirectory: string): string => {
if (!path.posix.isAbsolute(sandboxDirectory)) {
throw new Error("Sandbox directory must be absolute");
}
return path.posix.normalize(sandboxDirectory);
};
const enforceFileBounds = (
files: readonly MirrorFile[],
limits: RepositoryMirrorLimits
): void => {
if (files.length > limits.maxFiles) {
throw new Error(
`Repository contains ${files.length} files; limit is ${limits.maxFiles}`
);
}
let totalBytes = 0;
for (const file of files) {
if (file.size > limits.maxFileBytes) {
throw new Error(
`${file.path} is ${file.size} bytes; per-file limit is ${limits.maxFileBytes}`
);
}
totalBytes += file.size;
if (totalBytes > limits.maxTotalBytes) {
throw new Error(
`Repository files exceed the ${limits.maxTotalBytes}-byte total limit`
);
}
}
};
const listHostFiles = async (
checkoutDirectory: string,
limits: RepositoryMirrorLimits
): Promise<MirrorFile[]> => {
const files: MirrorFile[] = [];
const visit = async (
directory: string,
relativeDirectory: string
): Promise<void> => {
const depth = relativeDirectory
? relativeDirectory.split(path.sep).length
: 0;
if (depth > limits.maxDepth) {
throw new Error(`Repository directory depth exceeds ${limits.maxDepth}`);
}
const entries = await readdir(directory, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
if (!relativeDirectory && entry.name === ".git") {
continue;
}
const relativePath = path.join(relativeDirectory, entry.name);
const absolutePath = path.join(directory, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(`Symbolic links are not supported: ${relativePath}`);
}
if (entry.isDirectory()) {
// oxlint-disable-next-line no-await-in-loop -- deterministic bounded traversal.
await visit(absolutePath, relativePath);
continue;
}
if (!entry.isFile()) {
throw new Error(`Unsupported repository entry: ${relativePath}`);
}
// oxlint-disable-next-line no-await-in-loop -- metadata is bounded before file transfer.
const metadata = await lstat(absolutePath);
files.push({
mode: metadata.mode % 0o1000,
path: relativePath.split(path.sep).join(path.posix.sep),
size: metadata.size,
});
enforceFileBounds(files, limits);
}
};
await visit(checkoutDirectory, "");
return files;
};
const listSandboxFiles = async (
sandbox: SandboxApi,
sandboxDirectory: string,
limits: RepositoryMirrorLimits
): Promise<MirrorFile[]> => {
const files: MirrorFile[] = [];
const visit = async (
directory: string,
relativeDirectory: string
): Promise<void> => {
const depth = relativeDirectory
? relativeDirectory.split(path.posix.sep).length
: 0;
if (depth > limits.maxDepth) {
throw new Error(`Sandbox directory depth exceeds ${limits.maxDepth}`);
}
const entries = [...(await sandbox.readdir(directory))].toSorted(
(left, right) => left.localeCompare(right)
);
for (const entry of entries) {
assertSafeEntryName(entry);
if (!relativeDirectory && entry === ".git") {
continue;
}
const relativePath = path.posix.join(relativeDirectory, entry);
const absolutePath = path.posix.join(directory, entry);
// oxlint-disable-next-line no-await-in-loop -- sandbox metadata is read sequentially and bounded.
const metadata = await sandbox.stat(absolutePath);
if (metadata.isSymbolicLink) {
throw new Error(`Symbolic links are not supported: ${relativePath}`);
}
if (metadata.isDirectory) {
// oxlint-disable-next-line no-await-in-loop -- deterministic bounded traversal.
await visit(absolutePath, relativePath);
continue;
}
if (!metadata.isFile) {
throw new Error(`Unsupported sandbox entry: ${relativePath}`);
}
if (metadata.size === undefined) {
throw new Error(`Sandbox did not report a size for ${relativePath}`);
}
files.push({ path: relativePath, size: metadata.size });
enforceFileBounds(files, limits);
}
};
await visit(sandboxDirectory, "");
return files;
};
export const mirrorHostCheckoutToSandbox = async (options: {
readonly checkoutDirectory: string;
readonly limits?: Partial<RepositoryMirrorLimits>;
readonly sandbox: SandboxApi;
readonly sandboxDirectory: string;
}): Promise<void> => {
const checkoutDirectory = assertWithinCheckout(options.checkoutDirectory);
const sandboxDirectory = assertAbsoluteSandboxDirectory(
options.sandboxDirectory
);
const limits = resolveLimits(options.limits);
const files = await listHostFiles(checkoutDirectory, limits);
await options.sandbox.mkdir(sandboxDirectory, { recursive: true });
for (const file of files) {
const hostPath = path.join(
checkoutDirectory,
...file.path.split(path.posix.sep)
);
const sandboxPath = path.posix.join(sandboxDirectory, file.path);
// oxlint-disable-next-line no-await-in-loop -- bounded sequential writes avoid overwhelming remote sandboxes.
await options.sandbox.mkdir(path.posix.dirname(sandboxPath), {
recursive: true,
});
// oxlint-disable-next-line no-await-in-loop -- bounded sequential transfer limits peak memory.
const content = await readFile(hostPath);
if (content.byteLength !== file.size) {
throw new Error(`${file.path} changed while it was being mirrored`);
}
// oxlint-disable-next-line no-await-in-loop -- bounded sequential writes avoid overwhelming remote sandboxes.
await options.sandbox.writeFile(sandboxPath, content);
}
};
const clearCheckoutFiles = async (checkoutDirectory: string): Promise<void> => {
const entries = await readdir(checkoutDirectory);
for (const entry of entries) {
if (entry === ".git") {
continue;
}
// oxlint-disable-next-line no-await-in-loop -- deletion is deliberately scoped to checkout children.
await rm(path.join(checkoutDirectory, entry), {
force: true,
recursive: true,
});
}
};
export const mirrorSandboxToHostCheckout = async (options: {
readonly checkoutDirectory: string;
readonly limits?: Partial<RepositoryMirrorLimits>;
readonly sandbox: SandboxApi;
readonly sandboxDirectory: string;
}): Promise<void> => {
const checkoutDirectory = assertWithinCheckout(options.checkoutDirectory);
const sandboxDirectory = assertAbsoluteSandboxDirectory(
options.sandboxDirectory
);
const limits = resolveLimits(options.limits);
const files = await listSandboxFiles(
options.sandbox,
sandboxDirectory,
limits
);
const originalFiles = await listHostFiles(checkoutDirectory, limits);
const originalModes = new Map(
originalFiles.map((file) => [file.path, file.mode])
);
const stagingDirectory = await mkdtemp(
path.join(path.dirname(checkoutDirectory), ".zopu-sandbox-mirror-")
);
try {
let transferredBytes = 0;
for (const file of files) {
const sandboxPath = path.posix.join(sandboxDirectory, file.path);
// oxlint-disable-next-line no-await-in-loop -- bounded sequential reads limit peak memory.
const content = await options.sandbox.readFileBuffer(sandboxPath);
if (content.byteLength !== file.size) {
throw new Error(`${file.path} changed while it was being mirrored`);
}
transferredBytes += content.byteLength;
if (transferredBytes > limits.maxTotalBytes) {
throw new Error(
`Sandbox files exceed the ${limits.maxTotalBytes}-byte total limit`
);
}
const stagedPath = path.join(
stagingDirectory,
...file.path.split(path.posix.sep)
);
// oxlint-disable-next-line no-await-in-loop -- staged writes keep the checkout intact until transfer succeeds.
await mkdir(path.dirname(stagedPath), { recursive: true });
// oxlint-disable-next-line no-await-in-loop -- bounded sequential writes limit peak memory.
await writeFile(stagedPath, content);
const originalMode = originalModes.get(file.path);
if (originalMode !== undefined) {
// oxlint-disable-next-line no-await-in-loop -- preserve executable bits for existing files.
await chmod(stagedPath, originalMode);
}
}
await clearCheckoutFiles(checkoutDirectory);
const stagedEntries = await readdir(stagingDirectory);
for (const entry of stagedEntries) {
// oxlint-disable-next-line no-await-in-loop -- each top-level entry is moved exactly once.
await rename(
path.join(stagingDirectory, entry),
path.join(checkoutDirectory, entry)
);
}
} finally {
await rm(stagingDirectory, { force: true, recursive: true });
}
};

View File

@@ -1,7 +1,9 @@
import { parseAgentEnv } from "@code/env/agent"; import { parseAgentEnv } from "@code/env/agent";
import { defineTool } from "@flue/runtime"; import { ProjectIssueDispatchInput } from "@code/primitives/project-issue";
import { defineTool, dispatch } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser"; import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server"; import { makeFunctionReference } from "convex/server";
import { Schema } from "effect";
import * as v from "valibot"; import * as v from "valibot";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -103,9 +105,24 @@ const beginIssueRef = makeFunctionReference<
readonly issueId: string; readonly issueId: string;
readonly token: string; readonly token: string;
}, },
"queued" | "working" {
readonly dispatchRequired: boolean;
readonly projectId: string;
readonly status: "queued" | "working";
}
>("signalRouting:beginIssue"); >("signalRouting:beginIssue");
const markDispatchFailedRef = makeFunctionReference<
"mutation",
{
readonly error: string;
readonly issueId: string;
readonly organizationId: string;
readonly token: string;
},
null
>("signalRouting:markDispatchFailed");
const getProjectContextRef = makeFunctionReference< const getProjectContextRef = makeFunctionReference<
"query", "query",
{ {
@@ -306,11 +323,45 @@ export const createSignalRoutingTools = (
}), }),
name: "begin_issue", name: "begin_issue",
async run({ input }) { async run({ input }) {
return await client.mutation(beginIssueRef, { const outcome = await client.mutation(beginIssueRef, {
issueId: input.issueId, issueId: input.issueId,
organizationId, organizationId,
token, token,
}); });
const dispatchInput = Schema.decodeUnknownSync(
ProjectIssueDispatchInput
)({
issueId: input.issueId,
kind: "project.issue.started",
projectId: outcome.projectId,
});
if (!outcome.dispatchRequired) {
return {
acceptedAt: "",
dispatchId: "",
status: outcome.status,
};
}
try {
const receipt = await dispatch({
agent: "project-manager",
id: input.issueId,
input: dispatchInput,
});
return {
acceptedAt: receipt.acceptedAt,
dispatchId: receipt.dispatchId,
status: outcome.status,
};
} catch (error) {
await client.mutation(markDispatchFailedRef, {
error: error instanceof Error ? error.message : String(error),
issueId: input.issueId,
organizationId,
token,
});
throw error;
}
}, },
}), }),
]; ];

View File

@@ -0,0 +1,145 @@
import { parseAgentEnv } from "@code/env/agent";
import { defineTool } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
import * as v from "valibot";
const listProjectsRef = makeFunctionReference<
"query",
{ organizationId: string; token: string },
{ _id: string; name: string; description: string | null }[]
>("signalRouting:listProjects");
const listEvidenceRef = makeFunctionReference<
"query",
{ organizationId: string; token: string },
{ messageId: string; rawText: string; createdAt: number }[]
>("signalRouting:listEvidence");
const createSignalRef = makeFunctionReference<
"mutation",
{
organizationId: string;
projectId: string;
messageIds: string[];
problemStatement: {
title: string;
summary: string;
desiredOutcome: string;
constraints: string[];
};
processedByAgentInstanceId: string;
token: string;
},
{ signalId: string }
>("signalRouting:createSignal");
const listWorksRef = makeFunctionReference<
"query",
{ organizationId: string; projectId: string; token: string },
{ _id: string; title: string; objective: string; status: "proposed" }[]
>("works:listProposedForAgent");
const createWorkRef = makeFunctionReference<
"mutation",
{ organizationId: string; signalId: string; token: string },
{ created: boolean; workId: string }
>("works:createFromSignal");
const attachSignalRef = makeFunctionReference<
"mutation",
{ organizationId: string; signalId: string; workId: string; token: string },
{ attached: boolean; workId: string }
>("works:attachSignalToWork");
export const createSliceOneTools = (
organizationId: string,
runtimeEnv: Record<string, string | undefined>
) => {
const env = parseAgentEnv(runtimeEnv);
const client = new ConvexHttpClient(env.CONVEX_URL);
const token = env.FLUE_DB_TOKEN;
return [
defineTool({
description: "List the projects available in this organization.",
name: "list_projects",
async run() {
return await client.query(listProjectsRef, { organizationId, token });
},
}),
defineTool({
description:
"List exact admitted user messages that have not yet been used as Signal evidence.",
name: "list_signal_evidence",
async run() {
return await client.query(listEvidenceRef, { organizationId, token });
},
}),
defineTool({
description:
"Create one structured Signal from exact message IDs. Use only for actionable intent.",
input: v.object({
messageIds: v.array(v.string()),
problemStatement: v.object({
constraints: v.array(v.string()),
desiredOutcome: v.string(),
summary: v.string(),
title: v.string(),
}),
projectId: v.string(),
}),
name: "create_signal",
async run({ input }) {
return await client.mutation(createSignalRef, {
messageIds: input.messageIds,
organizationId,
problemStatement: input.problemStatement,
processedByAgentInstanceId: organizationId,
projectId: input.projectId,
token,
});
},
}),
defineTool({
description:
"List proposed Work for a project before deciding whether a new Signal belongs to existing Work.",
input: v.object({ projectId: v.string() }),
name: "list_proposed_work",
async run({ input }) {
return await client.query(listWorksRef, {
organizationId,
projectId: input.projectId,
token,
});
},
}),
defineTool({
description:
"Create one proposed Work from a Signal. This is idempotent for the same Signal.",
input: v.object({ signalId: v.string() }),
name: "create_work_from_signal",
async run({ input }) {
return await client.mutation(createWorkRef, {
organizationId,
signalId: input.signalId,
token,
});
},
}),
defineTool({
description:
"Attach a Signal to existing proposed Work when it describes the same desired outcome.",
input: v.object({ signalId: v.string(), workId: v.string() }),
name: "attach_signal_to_work",
async run({ input }) {
return await client.mutation(attachSignalRef, {
organizationId,
signalId: input.signalId,
token,
workId: input.workId,
});
},
}),
];
};

View File

@@ -29,6 +29,8 @@ import type * as publicGit from "../publicGit.js";
import type * as signalRouting from "../signalRouting.js"; import type * as signalRouting from "../signalRouting.js";
import type * as signals from "../signals.js"; import type * as signals from "../signals.js";
import type * as todos from "../todos.js"; import type * as todos from "../todos.js";
import type * as workflows from "../workflows.js";
import type * as works from "../works.js";
import type { import type {
ApiFromModules, ApiFromModules,
@@ -58,6 +60,8 @@ declare const fullApi: ApiFromModules<{
signalRouting: typeof signalRouting; signalRouting: typeof signalRouting;
signals: typeof signals; signals: typeof signals;
todos: typeof todos; todos: typeof todos;
workflows: typeof workflows;
works: typeof works;
}>; }>;
/** /**

View File

@@ -26,6 +26,8 @@ import type { DataModel } from "./dataModel.js";
*/ */
type Env = { type Env = {
readonly FLUE_DB_TOKEN: string; readonly FLUE_DB_TOKEN: string;
readonly GITEA_TOKEN: string | undefined;
readonly GITEA_URL: string | undefined;
readonly NATIVE_APP_URL: string | undefined; readonly NATIVE_APP_URL: string | undefined;
readonly SITE_URL: string; readonly SITE_URL: string;
}; };

View File

@@ -1,4 +1,5 @@
import { import {
decodePublicGitImportResult,
preparePublicGitSource, preparePublicGitSource,
type ProjectImportOutcome, type ProjectImportOutcome,
type ProjectView, type ProjectView,
@@ -8,15 +9,17 @@ import {
import { v } from "convex/values"; import { v } from "convex/values";
import { Effect } from "effect"; import { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel"; import type { Id } from "./_generated/dataModel";
import { action, internalMutation, query } from "./_generated/server"; import { action, internalMutation, query } from "./_generated/server";
import { createInitialArtifacts } from "./artifactModel"; import { createInitialArtifacts } from "./artifactModel";
import { requireCurrentOrganization } from "./authz"; import { requireAuthUserId, requireCurrentOrganization } from "./authz";
import { import {
makeProjectMutationStore, makeProjectMutationStore,
makeProjectQueryStore, makeProjectQueryStore,
persistPublicGitImportTransaction, persistPublicGitImportTransaction,
} from "./projectStore"; } from "./projectStore";
import { inspectPublicGit } from "./publicGit";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Internal: persist a public Git import in one transaction // Internal: persist a public Git import in one transaction
@@ -182,18 +185,31 @@ export const get = query({
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public action: import a public Git repository // Public action: import a supported public Git repository
// (daemon wiring added in the Daemon phase)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const importPublicGit = action({ export const importPublicGit = action({
args: { repositoryUrl: v.string() }, args: { repositoryUrl: v.string() },
handler: async (_ctx, args): Promise<ProjectImportOutcome> => { handler: async (ctx, args): Promise<ProjectImportOutcome> => {
// Normalize through the domain layer to validate the URL before the const userId = await requireAuthUserId(ctx);
// daemon adapter is wired. const source = await Effect.runPromise(
await Effect.runPromise(preparePublicGitSource(args.repositoryUrl)); preparePublicGitSource(args.repositoryUrl)
// The daemon PublicGit adapter is wired in the Daemon phase. );
throw new Error("PublicGit import is not yet wired to the daemon adapter"); const remote = await inspectPublicGit(source);
const validatedRemote = await Effect.runPromise(
decodePublicGitImportResult(remote)
);
return await ctx.runMutation(internal.projects.persistPublicGitImport, {
remote: {
defaultBranch: validatedRemote.defaultBranch,
documents: validatedRemote.documents.map((document) => ({
...document,
})),
warnings: validatedRemote.warnings.map((warning) => ({ ...warning })),
},
source,
userId,
});
}, },
}); });

View File

@@ -0,0 +1,58 @@
import { describe, expect, test } from "vitest";
import { inspectPublicGit } from "./publicGit";
const source = {
host: "git.openputer.com",
normalizedUrl: "https://git.openputer.com/sai-karthik/hello-world",
projectName: "hello-world",
repositoryPath: "Sai-karthik/hello-world",
url: "https://git.openputer.com/Sai-karthik/hello-world.git",
};
describe("public Git repository inspection", () => {
test("imports metadata for an empty public Gitea repository", async () => {
const requests: string[] = [];
const result = await inspectPublicGit(source, {
fetch(input) {
const url = String(input);
requests.push(url);
if (url.endsWith("/api/v1/repos/Sai-karthik/hello-world")) {
return Promise.resolve(
Response.json({
default_branch: "main",
empty: true,
})
);
}
return Promise.resolve(new Response("not found", { status: 404 }));
},
});
expect(result).toMatchObject({
defaultBranch: "main",
documents: [],
});
expect(result.warnings).toHaveLength(6);
expect(requests).toContain(
"https://git.openputer.com/api/v1/repos/Sai-karthik/hello-world"
);
expect(requests).toContain(
"https://git.openputer.com/api/v1/repos/Sai-karthik/hello-world/raw/README.md?ref=main"
);
});
test("rejects hosts outside the supported provider allowlist", async () => {
await expect(
inspectPublicGit({
...source,
host: "example.com",
normalizedUrl: "https://example.com/example/repository",
repositoryPath: "example/repository",
url: "https://example.com/example/repository.git",
})
).rejects.toThrow(
"Repository import supports public GitHub and git.openputer.com URLs"
);
});
});

View File

@@ -8,10 +8,31 @@ import {
} from "@code/primitives/project"; } from "@code/primitives/project";
const GITHUB_HOST = "github.com"; const GITHUB_HOST = "github.com";
const REQUEST_HEADERS = { const GITEA_HOST = "git.openputer.com";
const GITHUB_REQUEST_HEADERS = {
Accept: "application/vnd.github+json", Accept: "application/vnd.github+json",
"User-Agent": "zopu-project-importer", "User-Agent": "zopu-project-importer",
} as const; } as const;
const GITEA_REQUEST_HEADERS = {
Accept: "application/json",
"User-Agent": "zopu-project-importer",
} as const;
type Fetch = (
input: string | URL | Request,
init?: RequestInit
) => Promise<Response>;
interface PublicGitProvider {
readonly headers: Readonly<Record<string, string>>;
readonly metadataUrl: (source: PreparedPublicGitSource) => string;
readonly rawFileUrl: (
source: PreparedPublicGitSource,
branch: string,
path: string
) => string;
readonly repositoryNotFoundMessage: string;
}
const candidatePaths = { const candidatePaths = {
agents: ["AGENTS.md"], agents: ["AGENTS.md"],
@@ -28,18 +49,51 @@ const encodePath = (path: string) =>
.map((segment) => encodeURIComponent(segment)) .map((segment) => encodeURIComponent(segment))
.join("/"); .join("/");
const providerForSource = (
source: PreparedPublicGitSource
): PublicGitProvider => {
if (source.host === GITHUB_HOST) {
return {
headers: GITHUB_REQUEST_HEADERS,
metadataUrl: (candidate) =>
`https://api.github.com/repos/${encodePath(candidate.repositoryPath)}`,
rawFileUrl: (candidate, branch, path) =>
`https://raw.githubusercontent.com/${encodePath(candidate.repositoryPath)}/${encodePath(branch)}/${encodePath(path)}`,
repositoryNotFoundMessage: "Public GitHub repository not found",
};
}
if (source.host === GITEA_HOST) {
return {
headers: GITEA_REQUEST_HEADERS,
metadataUrl: (candidate) =>
`https://${GITEA_HOST}/api/v1/repos/${encodePath(candidate.repositoryPath)}`,
rawFileUrl: (candidate, branch, path) =>
`https://${GITEA_HOST}/api/v1/repos/${encodePath(candidate.repositoryPath)}/raw/${encodePath(path)}?ref=${encodeURIComponent(branch)}`,
repositoryNotFoundMessage: "Public Gitea repository not found",
};
}
throw new Error(
"Repository import supports public GitHub and git.openputer.com URLs"
);
};
const fetchTextCandidate = async ( const fetchTextCandidate = async (
repositoryPath: string, fetcher: Fetch,
provider: PublicGitProvider,
source: PreparedPublicGitSource,
branch: string, branch: string,
path: string path: string
): Promise<string | null> => { ): Promise<string | null> => {
const url = `https://raw.githubusercontent.com/${encodePath(repositoryPath)}/${encodePath(branch)}/${encodePath(path)}`; const response = await fetcher(provider.rawFileUrl(source, branch, path), {
const response = await fetch(url, { headers: REQUEST_HEADERS }); headers: provider.headers,
});
if (response.status === 404) { if (response.status === 404) {
return null; return null;
} }
if (!response.ok) { if (!response.ok) {
throw new Error(`GitHub returned ${response.status} for ${path}`); throw new Error(
`${source.host} returned ${response.status} while reading ${path}`
);
} }
const content = await response.text(); const content = await response.text();
if (content.trim().length === 0) { if (content.trim().length === 0) {
@@ -52,13 +106,17 @@ const fetchTextCandidate = async (
}; };
const fetchContextDocument = async ( const fetchContextDocument = async (
fetcher: Fetch,
provider: PublicGitProvider,
source: PreparedPublicGitSource, source: PreparedPublicGitSource,
branch: string, branch: string,
kind: (typeof CONTEXT_KINDS)[number] kind: (typeof CONTEXT_KINDS)[number]
): Promise<RepositoryContextDocument | null> => { ): Promise<RepositoryContextDocument | null> => {
for (const candidate of candidatePaths[kind]) { for (const candidate of candidatePaths[kind]) {
const content = await fetchTextCandidate( const content = await fetchTextCandidate(
source.repositoryPath, fetcher,
provider,
source,
branch, branch,
candidate candidate
); );
@@ -74,20 +132,18 @@ const fetchContextDocument = async (
}; };
const readDefaultBranch = async ( const readDefaultBranch = async (
fetcher: Fetch,
provider: PublicGitProvider,
source: PreparedPublicGitSource source: PreparedPublicGitSource
): Promise<string> => { ): Promise<string> => {
if (source.host !== GITHUB_HOST) { const response = await fetcher(provider.metadataUrl(source), {
throw new Error("Repository import currently supports public GitHub URLs"); headers: provider.headers,
} });
const response = await fetch(
`https://api.github.com/repos/${encodePath(source.repositoryPath)}`,
{ headers: REQUEST_HEADERS }
);
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
response.status === 404 response.status === 404
? "Public GitHub repository not found" ? provider.repositoryNotFoundMessage
: `GitHub returned ${response.status} while reading the repository` : `${source.host} returned ${response.status} while reading the repository`
); );
} }
const metadata = (await response.json()) as { default_branch?: unknown }; const metadata = (await response.json()) as { default_branch?: unknown };
@@ -95,18 +151,21 @@ const readDefaultBranch = async (
typeof metadata.default_branch !== "string" || typeof metadata.default_branch !== "string" ||
metadata.default_branch.length === 0 metadata.default_branch.length === 0
) { ) {
throw new Error("GitHub did not return a default branch"); throw new Error(`${source.host} did not return a default branch`);
} }
return metadata.default_branch; return metadata.default_branch;
}; };
export const inspectPublicGit = async ( export const inspectPublicGit = async (
source: PreparedPublicGitSource source: PreparedPublicGitSource,
options: { readonly fetch?: Fetch } = {}
): Promise<PublicGitImportResult> => { ): Promise<PublicGitImportResult> => {
const defaultBranch = await readDefaultBranch(source); const fetcher = options.fetch ?? globalThis.fetch;
const provider = providerForSource(source);
const defaultBranch = await readDefaultBranch(fetcher, provider, source);
const candidates = await Promise.all( const candidates = await Promise.all(
CONTEXT_KINDS.map((kind) => CONTEXT_KINDS.map((kind) =>
fetchContextDocument(source, defaultBranch, kind) fetchContextDocument(fetcher, provider, source, defaultBranch, kind)
) )
); );
const documents = candidates.filter( const documents = candidates.filter(

View File

@@ -282,6 +282,41 @@ export default defineSchema({
"messageId", "messageId",
]), ]),
works: defineTable({
organizationId: v.id("organizations"),
projectId: v.id("projects"),
title: v.string(),
objective: v.string(),
status: v.literal("proposed"),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_project_and_createdAt", ["projectId", "createdAt"])
.index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
signalWorkAttachments: defineTable({
organizationId: v.id("organizations"),
projectId: v.id("projects"),
signalId: v.id("signals"),
workId: v.id("works"),
createdAt: v.number(),
})
.index("by_signal", ["signalId"])
.index("by_work", ["workId"])
.index("by_signal_and_work", ["signalId", "workId"]),
workEvents: defineTable({
organizationId: v.id("organizations"),
projectId: v.id("projects"),
workId: v.id("works"),
kind: v.union(v.literal("work.proposed"), v.literal("signal.attached")),
idempotencyKey: v.string(),
data: v.any(),
createdAt: v.number(),
})
.index("by_work_and_createdAt", ["workId", "createdAt"])
.index("by_work_and_idempotencyKey", ["workId", "idempotencyKey"]),
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Signal-to-issue attachments. A proper relation that links one Signal // Signal-to-issue attachments. A proper relation that links one Signal
// to one ProjectIssue, idempotent by (signalId, issueId). Multiple // to one ProjectIssue, idempotent by (signalId, issueId). Multiple

View File

@@ -491,7 +491,22 @@ describe("signalRouting begin issue", () => {
token: TOKEN, token: TOKEN,
}); });
expect(status).toBe("queued"); expect(status).toEqual({
dispatchRequired: true,
projectId,
status: "queued",
});
const repeated = await t.mutation(api.signalRouting.beginIssue, {
issueId: result.issueId as string,
organizationId: orgId,
token: TOKEN,
});
expect(repeated).toEqual({
dispatchRequired: false,
projectId,
status: "queued",
});
}); });
test("begin rejects invalid token", async () => { test("begin rejects invalid token", async () => {

View File

@@ -613,7 +613,14 @@ export const beginIssue = mutation({
issueId: v.id("projectIssues"), issueId: v.id("projectIssues"),
token: v.string(), token: v.string(),
}, },
handler: async (ctx, args): Promise<"queued" | "working"> => { handler: async (
ctx,
args
): Promise<{
dispatchRequired: boolean;
projectId: Id<"projects">;
status: "queued" | "working";
}> => {
requireAgent(args.token); requireAgent(args.token);
const issue = await ctx.db.get(args.issueId); const issue = await ctx.db.get(args.issueId);
if (!issue) { if (!issue) {
@@ -629,7 +636,11 @@ export const beginIssue = mutation({
); );
}); });
if (status === issue.status) { if (status === issue.status) {
return status; return {
dispatchRequired: false,
projectId: issue.projectId,
status,
};
} }
const timestamp = Date.now(); const timestamp = Date.now();
@@ -644,12 +655,50 @@ export const beginIssue = mutation({
kind: "issue.queued", kind: "issue.queued",
projectId: issue.projectId, projectId: issue.projectId,
}); });
return status; return {
dispatchRequired: true,
projectId: issue.projectId,
status,
};
}, },
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 8. Get project summary + context documents for routing decisions. // 8. Mark a failed Flue dispatch so retrying can safely queue it again.
// ---------------------------------------------------------------------------
export const markDispatchFailed = mutation({
args: {
error: v.string(),
issueId: v.id("projectIssues"),
organizationId: v.id("organizations"),
token: v.string(),
},
handler: async (ctx, args) => {
requireAgent(args.token);
const issue = await ctx.db.get(args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId);
const timestamp = Date.now();
await ctx.db.patch(issue._id, {
status: "failed",
updatedAt: timestamp,
});
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { error: args.error.slice(0, 1000), phase: "dispatch" },
issueId: issue._id,
kind: "agent.failed",
projectId: issue.projectId,
});
return null;
},
});
// ---------------------------------------------------------------------------
// 9. Get project summary + context documents for routing decisions.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const getProjectContext = query({ export const getProjectContext = query({

View File

@@ -0,0 +1,224 @@
import { env } from "@code/env/convex";
import { convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
import { internal } from "./_generated/api";
import schema from "./schema";
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
const identity = { tokenIdentifier: "https://convex.test|slice-one" };
describe("Slice 1 Work routing", () => {
test("creates one proposed Work and preserves exact source text", async () => {
const t = convexTest({ schema, modules });
const organization = await t
.withIdentity(identity)
.mutation(api.organizations.ensurePersonalOrganization, {});
const project = await t
.withIdentity(identity)
.mutation(internal.projects.persistPublicGitImport, {
remote: { defaultBranch: "main", documents: [], warnings: [] },
source: {
host: "github.com",
normalizedUrl: "https://github.com/example/slice-one",
projectName: "slice-one",
repositoryPath: "example/slice-one",
url: "https://github.com/example/slice-one",
},
userId: identity.tokenIdentifier,
});
const rawText = " Add a phone-ready Slice 1 experience. ";
const message = await t
.withIdentity(identity)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "slice-one-request",
organizationId: organization._id,
rawText,
});
await t
.withIdentity(identity)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId: "slice-one-request",
organizationId: organization._id,
submissionId: "slice-one-submission",
});
const signal = await t.mutation(api.signalRouting.createSignal, {
messageIds: [message.messageId],
organizationId: organization._id,
problemStatement: {
constraints: ["Mobile web first"],
desiredOutcome: "The Slice 1 loop works on a phone.",
summary: "The current product is not ready for phone testing.",
title: "Make Slice 1 phone-ready",
},
processedByAgentInstanceId: organization._id,
projectId: project.id,
token: env.FLUE_DB_TOKEN,
});
const first = await t.mutation(api.works.createFromSignal, {
organizationId: organization._id,
signalId: signal.signalId,
token: env.FLUE_DB_TOKEN,
});
const repeated = await t.mutation(api.works.createFromSignal, {
organizationId: organization._id,
signalId: signal.signalId,
token: env.FLUE_DB_TOKEN,
});
expect(repeated).toEqual({ created: false, workId: first.workId });
const works = await t
.withIdentity(identity)
.query(api.works.listForProject, { projectId: project.id });
expect(works).toHaveLength(1);
expect(works[0]?.status).toBe("proposed");
expect(works[0]?.signals[0]?.sources[0]?.rawText).toBe(rawText);
});
test("keeps casual conversation out of Work", async () => {
const t = convexTest({ schema, modules });
const organization = await t
.withIdentity(identity)
.mutation(api.organizations.ensurePersonalOrganization, {});
const project = await t
.withIdentity(identity)
.mutation(internal.projects.persistPublicGitImport, {
remote: { defaultBranch: "main", documents: [], warnings: [] },
source: {
host: "git.openputer.com",
normalizedUrl: "https://git.openputer.com/puter/zopu-code",
projectName: "zopu-code",
repositoryPath: "puter/zopu-code",
url: "https://git.openputer.com/puter/zopu-code",
},
userId: identity.tokenIdentifier,
});
await t
.withIdentity(identity)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "slice-one-casual",
organizationId: organization._id,
rawText: "Yo, how are you?",
});
await t
.withIdentity(identity)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId: "slice-one-casual",
organizationId: organization._id,
submissionId: "slice-one-casual-submission",
});
const works = await t
.withIdentity(identity)
.query(api.works.listForProject, { projectId: project.id });
expect(works).toEqual([]);
});
test("attaches another Signal to existing Work idempotently", async () => {
const t = convexTest({ schema, modules });
const organization = await t
.withIdentity(identity)
.mutation(api.organizations.ensurePersonalOrganization, {});
const project = await t
.withIdentity(identity)
.mutation(internal.projects.persistPublicGitImport, {
remote: { defaultBranch: "main", documents: [], warnings: [] },
source: {
host: "git.openputer.com",
normalizedUrl: "https://git.openputer.com/puter/zopu-code",
projectName: "zopu-code",
repositoryPath: "puter/zopu-code",
url: "https://git.openputer.com/puter/zopu-code",
},
userId: identity.tokenIdentifier,
});
const seedSignal = async (
clientRequestId: string,
rawText: string,
title: string
) => {
const message = await t
.withIdentity(identity)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId,
organizationId: organization._id,
rawText,
});
await t
.withIdentity(identity)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId,
organizationId: organization._id,
submissionId: `${clientRequestId}-submission`,
});
return await t.mutation(api.signalRouting.createSignal, {
messageIds: [message.messageId],
organizationId: organization._id,
problemStatement: {
constraints: [],
desiredOutcome: "The Slice 1 phone flow is polished.",
summary: "The messages describe the same desired outcome.",
title,
},
processedByAgentInstanceId: organization._id,
projectId: project.id,
token: env.FLUE_DB_TOKEN,
});
};
const firstSignal = await seedSignal(
"slice-one-first",
"Polish the Slice 1 phone experience.",
"Polish Slice 1"
);
const secondSignal = await seedSignal(
"slice-one-second",
"Make the same Slice 1 experience easier to inspect on mobile.",
"Improve Slice 1 inspection"
);
const created = await t.mutation(api.works.createFromSignal, {
organizationId: organization._id,
signalId: firstSignal.signalId,
token: env.FLUE_DB_TOKEN,
});
const firstAttach = await t.mutation(api.works.attachSignalToWork, {
organizationId: organization._id,
signalId: secondSignal.signalId,
token: env.FLUE_DB_TOKEN,
workId: created.workId,
});
const repeatedAttach = await t.mutation(api.works.attachSignalToWork, {
organizationId: organization._id,
signalId: secondSignal.signalId,
token: env.FLUE_DB_TOKEN,
workId: created.workId,
});
expect(firstAttach).toEqual({ attached: true, workId: created.workId });
expect(repeatedAttach).toEqual({
attached: false,
workId: created.workId,
});
const works = await t
.withIdentity(identity)
.query(api.works.listForProject, { projectId: project.id });
expect(works).toHaveLength(1);
expect(works[0]?.signals).toHaveLength(2);
expect(
works[0]?.events.filter(
(event: { readonly kind: string }) => event.kind === "signal.attached"
)
).toHaveLength(1);
});
});

View File

@@ -0,0 +1,267 @@
import { env } from "@code/env/convex";
import {
signalAttachedEvent,
workDraftFromSignal,
workProposedEventFromSignal,
} from "@code/work-os";
import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import type { Doc, Id } from "./_generated/dataModel";
import { mutation, type MutationCtx, query } from "./_generated/server";
import { requireProjectMember } from "./authz";
const requireAgent = (token: string) => {
if (token !== env.FLUE_DB_TOKEN) {
throw new ConvexError("Invalid agent control token");
}
};
const requireSignal = async (
ctx: MutationCtx,
organizationId: Id<"organizations">,
signalId: Id<"signals">
): Promise<Doc<"signals">> => {
const signal = await ctx.db.get(signalId);
if (!signal || signal.organizationId !== organizationId) {
throw new ConvexError("Signal not found");
}
if (!signal.projectId) {
throw new ConvexError("Signal must belong to a project");
}
return signal;
};
const requireWork = async (
ctx: MutationCtx,
organizationId: Id<"organizations">,
workId: Id<"works">
): Promise<Doc<"works">> => {
const work = await ctx.db.get(workId);
if (!work || work.organizationId !== organizationId) {
throw new ConvexError("Work not found");
}
return work;
};
const attachSignal = async (
ctx: MutationCtx,
signal: Doc<"signals">,
work: Doc<"works">
): Promise<boolean> => {
const event = await Effect.runPromise(
signalAttachedEvent({
signal: {
organizationId: String(signal.organizationId),
projectId: String(signal.projectId),
signalId: String(signal._id),
title: signal.problemStatement.title,
},
work: {
organizationId: String(work.organizationId),
projectId: String(work.projectId),
workId: String(work._id),
},
})
).catch((error: unknown) => {
throw new ConvexError(
error instanceof Error ? error.message : "Invalid Work attachment"
);
});
const existing = await ctx.db
.query("signalWorkAttachments")
.withIndex("by_signal_and_work", (q) =>
q.eq("signalId", signal._id).eq("workId", work._id)
)
.unique();
if (existing) {
return false;
}
const createdAt = Date.now();
await ctx.db.insert("signalWorkAttachments", {
createdAt,
organizationId: work.organizationId,
projectId: work.projectId,
signalId: signal._id,
workId: work._id,
});
await ctx.db.insert("workEvents", {
createdAt,
data: event.data,
idempotencyKey: event.idempotencyKey,
kind: event.kind,
organizationId: work.organizationId,
projectId: work.projectId,
workId: work._id,
});
await ctx.db.patch(work._id, { updatedAt: createdAt });
return true;
};
export const listProposedForAgent = query({
args: {
organizationId: v.id("organizations"),
projectId: v.id("projects"),
token: v.string(),
},
handler: async (ctx, args) => {
requireAgent(args.token);
const project = await ctx.db.get(args.projectId);
if (!project || project.organizationId !== args.organizationId) {
throw new ConvexError("Project not found");
}
return await ctx.db
.query("works")
.withIndex("by_project_and_createdAt", (q) =>
q.eq("projectId", args.projectId)
)
.order("desc")
.take(50);
},
});
export const createFromSignal = mutation({
args: {
organizationId: v.id("organizations"),
signalId: v.id("signals"),
token: v.string(),
},
handler: async (ctx, args) => {
requireAgent(args.token);
const signal = await requireSignal(ctx, args.organizationId, args.signalId);
const existingAttachment = await ctx.db
.query("signalWorkAttachments")
.withIndex("by_signal", (q) => q.eq("signalId", signal._id))
.first();
if (existingAttachment) {
return { created: false, workId: existingAttachment.workId };
}
const draft = await Effect.runPromise(
workDraftFromSignal(signal.problemStatement)
).catch((error: unknown) => {
throw new ConvexError(
error instanceof Error ? error.message : "Invalid Work proposal"
);
});
const createdAt = Date.now();
const event = await Effect.runPromise(
workProposedEventFromSignal({
organizationId: String(signal.organizationId),
projectId: String(signal.projectId),
signalId: String(signal._id),
title: draft.title,
})
).catch((error: unknown) => {
throw new ConvexError(
error instanceof Error ? error.message : "Invalid Work event"
);
});
const workId = await ctx.db.insert("works", {
createdAt,
objective: draft.objective,
organizationId: signal.organizationId,
projectId: signal.projectId as Id<"projects">,
status: "proposed",
title: draft.title,
updatedAt: createdAt,
});
await ctx.db.insert("signalWorkAttachments", {
createdAt,
organizationId: signal.organizationId,
projectId: signal.projectId as Id<"projects">,
signalId: signal._id,
workId,
});
await ctx.db.insert("workEvents", {
createdAt,
data: event.data,
idempotencyKey: event.idempotencyKey,
kind: event.kind,
organizationId: signal.organizationId,
projectId: signal.projectId as Id<"projects">,
workId,
});
return { created: true, workId };
},
});
export const attachSignalToWork = mutation({
args: {
organizationId: v.id("organizations"),
signalId: v.id("signals"),
token: v.string(),
workId: v.id("works"),
},
handler: async (ctx, args) => {
requireAgent(args.token);
const signal = await requireSignal(ctx, args.organizationId, args.signalId);
const work = await requireWork(ctx, args.organizationId, args.workId);
return {
attached: await attachSignal(ctx, signal, work),
workId: work._id,
};
},
});
export const listForProject = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
await requireProjectMember(ctx, args.projectId);
const works = await ctx.db
.query("works")
.withIndex("by_project_and_createdAt", (q) =>
q.eq("projectId", args.projectId)
)
.order("desc")
.take(100);
return await Promise.all(
works.map(async (work) => {
const attachments = await ctx.db
.query("signalWorkAttachments")
.withIndex("by_work", (q) => q.eq("workId", work._id))
.collect();
const signals = await Promise.all(
attachments.map(async (attachment) => {
const signal = await ctx.db.get(attachment.signalId);
if (!signal) {
return null;
}
const sources = await ctx.db
.query("signalSources")
.withIndex("by_signal_and_ordinal", (q) =>
q.eq("signalId", signal._id)
)
.collect();
return {
createdAt: signal.createdAt,
signalId: signal._id,
summary: signal.problemStatement.summary,
sources: sources
.sort((a, b) => a.ordinal - b.ordinal)
.map((source) => ({
createdAt: source.sourceCreatedAt,
messageId: source.messageId,
rawText: source.rawTextSnapshot,
submissionId: source.submissionId ?? null,
})),
title: signal.problemStatement.title,
};
})
);
const events = await ctx.db
.query("workEvents")
.withIndex("by_work_and_createdAt", (q) => q.eq("workId", work._id))
.order("desc")
.collect();
return {
...work,
events,
signals: signals.filter((signal) => signal !== null),
};
})
);
},
});

View File

@@ -15,6 +15,7 @@
"@better-auth/expo": "catalog:", "@better-auth/expo": "catalog:",
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@code/primitives": "workspace:*", "@code/primitives": "workspace:*",
"@code/work-os": "workspace:*",
"@convex-dev/better-auth": "catalog:", "@convex-dev/better-auth": "catalog:",
"better-auth": "catalog:", "better-auth": "catalog:",
"convex": "catalog:", "convex": "catalog:",
@@ -24,8 +25,8 @@
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"typescript": "catalog:",
"convex-test": "catalog:", "convex-test": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
} }
} }

View File

@@ -13,6 +13,7 @@ const agentEnvSchema = z.object({
FLUE_DB_TOKEN: z.string().min(1), FLUE_DB_TOKEN: z.string().min(1),
GITEA_TOKEN: z.string().min(1).optional(), GITEA_TOKEN: z.string().min(1).optional(),
GITEA_URL: z.url().default("https://git.openputer.com"), GITEA_URL: z.url().default("https://git.openputer.com"),
OPENROUTER_API_KEY: z.string().min(1).optional(),
}); });
export type AgentEnv = z.infer<typeof agentEnvSchema>; export type AgentEnv = z.infer<typeof agentEnvSchema>;

View File

@@ -0,0 +1,85 @@
import { readFileSync } from "node:fs";
import git from "@agentos-software/git";
import { describe, expect, it } from "vitest";
import {
getExecutableGitSoftware,
makeGitExecutablesRunnable,
} from "./agent-os-git-software";
const TAR_BLOCK_SIZE = 512;
const READ_ONLY_EXECUTABLE_MODE = Buffer.from([0xa4, 0x81, 0x00, 0x00]);
const RUNNABLE_EXECUTABLE_MODE = Buffer.from([0xed, 0x81, 0x00, 0x00]);
const readTarModes = (packageBytes: Buffer): Map<string, number> => {
const tarOffset =
16 + packageBytes.readUInt32LE(8) + packageBytes.readUInt32LE(12);
const modes = new Map<string, number>();
let offset = tarOffset;
while (
offset + TAR_BLOCK_SIZE <= packageBytes.length &&
packageBytes
.subarray(offset, offset + TAR_BLOCK_SIZE)
.some((byte) => byte !== 0)
) {
const [name = ""] = packageBytes
.subarray(offset, offset + 100)
.toString("utf-8")
.split("\0");
const mode = Number.parseInt(
packageBytes
.subarray(offset + 100, offset + 108)
.toString("ascii")
.replaceAll("\0", "")
.trim(),
8
);
const size = Number.parseInt(
packageBytes
.subarray(offset + 124, offset + 136)
.toString("ascii")
.replaceAll("\0", "")
.trim() || "0",
8
);
modes.set(name, mode);
offset +=
TAR_BLOCK_SIZE + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
}
return modes;
};
describe("AgentOS Git software", () => {
it("marks the packaged Git commands executable", () => {
const repaired = makeGitExecutablesRunnable(readFileSync(git.packagePath));
const modes = readTarModes(repaired);
expect(modes.get("./bin/git")).toBe(0o755);
expect(modes.get("./bin/git-remote-http")).toBe(0o755);
expect(modes.get("./bin/git-remote-https")).toBe(0o755);
expect(repaired.includes(Buffer.from("0.3.0-zp.1", "ascii"))).toBe(true);
const mountIndexOffset = 16 + repaired.readUInt32LE(8);
const tarOffset = mountIndexOffset + repaired.readUInt32LE(12);
const mountIndex = repaired.subarray(mountIndexOffset, tarOffset);
expect(mountIndex.includes(READ_ONLY_EXECUTABLE_MODE)).toBe(false);
expect(
mountIndex.toString("hex").split(RUNNABLE_EXECUTABLE_MODE.toString("hex"))
.length - 1
).toBe(3);
});
it("writes a stable repaired package for the AgentOS registry", () => {
const first = getExecutableGitSoftware();
const second = getExecutableGitSoftware();
expect(second).toEqual(first);
expect(readTarModes(readFileSync(first.packagePath)).get("./bin/git")).toBe(
0o755
);
});
});

View File

@@ -0,0 +1,194 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import git from "@agentos-software/git";
const AOS_PACKAGE_MAGIC = Buffer.from([0x89, 0x41, 0x4f, 0x53]);
const TAR_BLOCK_SIZE = 512;
const TAR_CHECKSUM_OFFSET = 148;
const TAR_CHECKSUM_SIZE = 8;
const TAR_MODE_OFFSET = 100;
const TAR_MODE_SIZE = 8;
const TAR_NAME_SIZE = 100;
const TAR_SIZE_OFFSET = 124;
const TAR_SIZE_SIZE = 12;
const GIT_EXECUTABLES = new Set([
"./bin/git",
"./bin/git-remote-http",
"./bin/git-remote-https",
]);
const UPSTREAM_GIT_PACKAGE_VERSION = Buffer.from("0.3.0-rc.2", "ascii");
const REPAIRED_GIT_PACKAGE_VERSION = Buffer.from("0.3.0-zp.1", "ascii");
const READ_ONLY_EXECUTABLE_MODE = Buffer.from([0xa4, 0x81, 0x00, 0x00]);
const RUNNABLE_EXECUTABLE_MODE = Buffer.from([0xed, 0x81, 0x00, 0x00]);
const parseTarOctal = (
packageBytes: Buffer,
offset: number,
length: number
): number => {
const value = packageBytes
.subarray(offset, offset + length)
.toString("ascii")
.replaceAll("\0", "")
.trim();
return value.length === 0 ? 0 : Number.parseInt(value, 8);
};
const readTarName = (packageBytes: Buffer, headerOffset: number): string => {
const [name = ""] = packageBytes
.subarray(headerOffset, headerOffset + TAR_NAME_SIZE)
.toString("utf-8")
.split("\0");
return name;
};
const isEmptyTarHeader = (
packageBytes: Buffer,
headerOffset: number
): boolean =>
packageBytes
.subarray(headerOffset, headerOffset + TAR_BLOCK_SIZE)
.every((byte) => byte === 0);
const writeTarChecksum = (packageBytes: Buffer, headerOffset: number): void => {
packageBytes.fill(
0x20,
headerOffset + TAR_CHECKSUM_OFFSET,
headerOffset + TAR_CHECKSUM_OFFSET + TAR_CHECKSUM_SIZE
);
let checksum = 0;
for (
let index = headerOffset;
index < headerOffset + TAR_BLOCK_SIZE;
index += 1
) {
checksum += packageBytes[index] ?? 0;
}
const encodedChecksum = `${checksum.toString(8).padStart(6, "0")}\0 `;
packageBytes.write(
encodedChecksum,
headerOffset + TAR_CHECKSUM_OFFSET,
TAR_CHECKSUM_SIZE,
"ascii"
);
};
const getTarOffset = (packageBytes: Buffer): number => {
if (
packageBytes.length < 16 ||
!packageBytes
.subarray(0, AOS_PACKAGE_MAGIC.length)
.equals(AOS_PACKAGE_MAGIC)
) {
throw new Error("Invalid AgentOS package header");
}
return 16 + packageBytes.readUInt32LE(8) + packageBytes.readUInt32LE(12);
};
export const makeGitExecutablesRunnable = (source: Uint8Array): Buffer => {
const packageBytes = Buffer.from(source);
const mountIndexOffset = 16 + packageBytes.readUInt32LE(8);
const tarOffset = getTarOffset(packageBytes);
const versionOffset = packageBytes.indexOf(
UPSTREAM_GIT_PACKAGE_VERSION,
0,
"ascii"
);
if (versionOffset === -1 || versionOffset >= tarOffset) {
throw new Error("Expected AgentOS Git package version was not found");
}
REPAIRED_GIT_PACKAGE_VERSION.copy(packageBytes, versionOffset);
let mountModeOffset = mountIndexOffset;
let repairedMountEntries = 0;
while (mountModeOffset < tarOffset) {
mountModeOffset = packageBytes.indexOf(
READ_ONLY_EXECUTABLE_MODE,
mountModeOffset
);
if (mountModeOffset < 0 || mountModeOffset >= tarOffset) {
break;
}
RUNNABLE_EXECUTABLE_MODE.copy(packageBytes, mountModeOffset);
repairedMountEntries += 1;
mountModeOffset += RUNNABLE_EXECUTABLE_MODE.length;
}
if (repairedMountEntries !== GIT_EXECUTABLES.size) {
throw new Error(
`Expected ${GIT_EXECUTABLES.size} Git mount entries, repaired ${repairedMountEntries}`
);
}
let headerOffset = tarOffset;
let repairedExecutables = 0;
while (
headerOffset + TAR_BLOCK_SIZE <= packageBytes.length &&
!isEmptyTarHeader(packageBytes, headerOffset)
) {
const name = readTarName(packageBytes, headerOffset);
const size = parseTarOctal(
packageBytes,
headerOffset + TAR_SIZE_OFFSET,
TAR_SIZE_SIZE
);
if (GIT_EXECUTABLES.has(name)) {
packageBytes.write(
"0000755\0",
headerOffset + TAR_MODE_OFFSET,
TAR_MODE_SIZE,
"ascii"
);
writeTarChecksum(packageBytes, headerOffset);
repairedExecutables += 1;
}
headerOffset +=
TAR_BLOCK_SIZE + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
}
if (repairedExecutables !== GIT_EXECUTABLES.size) {
throw new Error(
`Expected ${GIT_EXECUTABLES.size} Git executables, repaired ${repairedExecutables}`
);
}
return packageBytes;
};
let cachedGitSoftware: { readonly packagePath: string } | undefined;
export const getExecutableGitSoftware = (): {
readonly packagePath: string;
} => {
if (cachedGitSoftware) {
return cachedGitSoftware;
}
const source = readFileSync(git.packagePath);
const repaired = makeGitExecutablesRunnable(source);
const digest = createHash("sha256")
.update(repaired)
.digest("hex")
.slice(0, 16);
const cacheDirectory = path.join(tmpdir(), "zopu-agentos-software");
const packagePath = path.join(cacheDirectory, `git-${digest}.aospkg`);
mkdirSync(cacheDirectory, { recursive: true });
if (!existsSync(packagePath)) {
writeFileSync(packagePath, repaired);
}
cachedGitSoftware = { packagePath };
return cachedGitSoftware;
};

View File

@@ -1,15 +1,16 @@
/* eslint-disable max-classes-per-file -- the AgentOS service and its tagged error form one adapter contract. */ /* eslint-disable max-classes-per-file -- the AgentOS service and its tagged error form one adapter contract. */
import codex from "@agentos-software/codex-cli"; import codex from "@agentos-software/codex-cli";
import git from "@agentos-software/git";
import { agentOS as createAgentOsActor } from "@rivet-dev/agentos"; import { agentOS as createAgentOsActor } from "@rivet-dev/agentos";
import type { AgentOSConfigInput } from "@rivet-dev/agentos"; import type { AgentOSConfigInput } from "@rivet-dev/agentos";
import { Context, Effect, Layer, Schema } from "effect"; import { Context, Effect, Layer, Schema } from "effect";
import { getExecutableGitSoftware } from "./agent-os-git-software";
const withGitSoftware = ( const withGitSoftware = (
config: AgentOSConfigInput<undefined> config: AgentOSConfigInput<undefined>
): AgentOSConfigInput<undefined> => ({ ): AgentOSConfigInput<undefined> => ({
...config, ...config,
software: [git, ...(config.software ?? [])], software: [getExecutableGitSoftware(), ...(config.software ?? [])],
}); });
export class AgentOsError extends Schema.TaggedErrorClass<AgentOsError>()( export class AgentOsError extends Schema.TaggedErrorClass<AgentOsError>()(
@@ -83,7 +84,7 @@ export const createAgentOsActorEffect = Effect.fn("createAgentOsActorEffect")(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Software bundle for a Codex-capable VM: the Codex harness plus git. */ /** Software bundle for a Codex-capable VM: the Codex harness plus git. */
export const codexSoftware = [codex, git] as const; export const codexSoftware = [codex, getExecutableGitSoftware()] as const;
/** Model-provider env that Codex reads inside the VM. Pass this to the session's /** Model-provider env that Codex reads inside the VM. Pass this to the session's
* `env` when opening a Codex session (`agent: "codex"`). */ * `env` when opening a Codex session (`agent: "codex"`). */

View File

@@ -73,6 +73,16 @@ describe("project workspace primitives", () => {
operation._tag === "Exec" && operation.command.includes("git clone") operation._tag === "Exec" && operation.command.includes("git clone")
) )
).toBe(true); ).toBe(true);
const checkoutOperation = plan.operations.find(
(operation) => operation._tag === "Exec"
);
expect(checkoutOperation?._tag).toBe("Exec");
if (checkoutOperation?._tag === "Exec") {
expect(checkoutOperation.command).not.toContain("--single-branch");
expect(checkoutOperation.command).toContain(
"checkout -b 'work/issue-7-abc123'"
);
}
}); });
it("rejects unsafe sources and control-file paths", () => { it("rejects unsafe sources and control-file paths", () => {

View File

@@ -260,17 +260,18 @@ const makeCheckoutCommand = (input: {
readonly sourceUrl: string; readonly sourceUrl: string;
}): string => { }): string => {
const checkout = shellQuote(input.checkoutPath); const checkout = shellQuote(input.checkoutPath);
const baseBranch = shellQuote(input.baseBranch);
const branch = shellQuote(input.branchName); const branch = shellQuote(input.branchName);
const originBaseBranch = shellQuote(`origin/${input.baseBranch}`); const branchRef = shellQuote(
`${input.checkoutPath}/.git/refs/heads/${input.branchName}`
);
const source = shellQuote(input.sourceUrl); const source = shellQuote(input.sourceUrl);
return [ return [
"set -eu", "set -eu",
`mkdir -p ${shellQuote(WORKSPACE_PATH)}`, `mkdir -p ${shellQuote(WORKSPACE_PATH)}`,
`if [ ! -d ${checkout}/.git ]; then git clone --branch ${baseBranch} --single-branch ${source} ${checkout}; fi`, `if [ ! -d ${checkout}/.git ]; then git clone ${source} ${checkout}; fi`,
`if [ "$(git -C ${checkout} branch --show-current)" != ${branch} ]; then git -C ${checkout} show-ref --verify --quiet refs/heads/${branch} && git -C ${checkout} checkout ${branch} || git -C ${checkout} checkout -b ${branch} ${originBaseBranch}; fi`, `if [ -f ${branchRef} ]; then git -C ${checkout} checkout ${branch}; else git -C ${checkout} checkout -b ${branch}; fi`,
`printf '%s\\n' "$(git -C ${checkout} branch --show-current)" "$(git -C ${checkout} rev-parse HEAD)"`, `printf '%s\\n' ${branch} "$(git -C ${checkout} rev-parse HEAD)"`,
].join(" && "); ].join(" && ");
}; };

Some files were not shown because too many files have changed in this diff Show More