From d52985f61ee56e6e7c70c6db9eca565ac85d9c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 23 Mar 2026 23:31:48 -0400 Subject: [PATCH] feat: deliverable review gate, app lifecycle fixes, CI/CD pipeline - Add deliverable review gate: pause workflow when agents produce important documents (PRDs, design docs, etc.) for human review before continuing - Add company-level reviewDeliverables setting with onboarding card selector - Block agent heartbeats when deliverable review is pending (agent + CEO) - Trigger review at document creation time in API server for reliability - Fix autoApproveHires vs reviewDeliverables conflict in approval-policy - Fix app quit: force-exit on all platforms (macOS Dock, Windows tray, Linux) - Add multi-platform CI/CD pipeline (macOS arm64/x64, Windows x64/arm64, Linux x64/arm64 with deb/AppImage/rpm/snap) with code signing support Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 227 ++++++++++++++++++ .gitignore | 9 + assets/entitlements.mac.plist | 18 ++ electron-builder.yml | 65 ++++- package.json | 2 +- src/main/api-server.ts | 78 +++++- src/main/app-lifecycle-service.ts | 8 +- src/main/approval-policy.ts | 13 +- src/main/automation-service.ts | 133 ++++++++++ src/main/database.ts | 64 ++++- src/main/ipc-core-work-graph-handlers.ts | 4 + src/main/onboarding-service.ts | 2 + src/main/orchestration-service.ts | 14 ++ src/main/service-graph.ts | 4 + src/main/worker-service.ts | 11 +- src/renderer/App.tsx | 1 + .../CompanyOnboarding/CompanyStep.tsx | 43 ++-- .../CompanyOnboarding/OnboardingWizard.tsx | 5 + .../execution/ApprovalInspector.tsx | 52 +++- .../components/execution/SettingsPane.tsx | 5 +- src/renderer/i18n/en.ts | 9 + src/renderer/i18n/zh.ts | 9 + src/shared/contracts.ts | 4 +- src/shared/types.ts | 5 +- 24 files changed, 737 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 assets/entitlements.mac.plist diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..406784a0 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,227 @@ +name: Build & Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + publish: + description: 'Publish to GitHub Releases' + required: false + type: boolean + default: false + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +env: + PNPM_VERSION: '10.24.0' + NODE_VERSION: '22' + +jobs: + # ───────────────────────────────────────────── + # Stage 1: Lint & Typecheck (fast feedback) + # ───────────────────────────────────────────── + check: + name: Lint & Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm exec tsc --noEmit + + # ───────────────────────────────────────────── + # Stage 2: Unit Tests + # ───────────────────────────────────────────── + test: + name: Unit Tests + runs-on: ubuntu-latest + needs: check + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test + + # ───────────────────────────────────────────── + # Stage 3: Build matrix (parallel per platform) + # ───────────────────────────────────────────── + + build-macos: + name: macOS (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + needs: test + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + runner: macos-14 + builder_args: --mac --arm64 + - arch: x64 + runner: macos-13 + builder_args: --mac --x64 + env: + CSC_LINK: ${{ secrets.MAC_CERTIFICATE_P12_BASE64 }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + CSC_IDENTITY_AUTO_DISCOVERY: 'true' + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Build electron-vite + run: pnpm exec electron-vite build + - name: Package & sign + run: pnpm exec electron-builder ${{ matrix.builder_args }} --config electron-builder.yml --publish never + - uses: actions/upload-artifact@v4 + with: + name: macos-${{ matrix.arch }} + path: | + build/release/*.dmg + build/release/*.zip + build/release/*.blockmap + build/release/latest-mac.yml + if-no-files-found: error + + build-windows: + name: Windows (${{ matrix.arch }}) + runs-on: windows-latest + needs: test + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + builder_args: --win --x64 + - arch: arm64 + builder_args: --win --arm64 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Build electron-vite + run: pnpm exec electron-vite build + - name: Package + run: pnpm exec electron-builder ${{ matrix.builder_args }} --config electron-builder.yml --publish never + env: + CSC_LINK: ${{ secrets.WIN_CERTIFICATE_P12_BASE64 }} + CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }} + - uses: actions/upload-artifact@v4 + with: + name: windows-${{ matrix.arch }} + path: | + build/release/*.exe + build/release/*.blockmap + build/release/latest.yml + if-no-files-found: error + + build-linux: + name: Linux (${{ matrix.arch }}) + runs-on: ubuntu-latest + needs: test + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + builder_args: --linux --x64 + - arch: arm64 + builder_args: --linux --arm64 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Install snapcraft (x64 only) + if: matrix.arch == 'x64' + run: sudo snap install snapcraft --classic + - name: Build electron-vite + run: pnpm exec electron-vite build + - name: Package + run: pnpm exec electron-builder ${{ matrix.builder_args }} --config electron-builder.yml --publish never + - uses: actions/upload-artifact@v4 + with: + name: linux-${{ matrix.arch }} + path: | + build/release/*.AppImage + build/release/*.deb + build/release/*.rpm + build/release/*.snap + build/release/*.blockmap + build/release/latest-linux.yml + if-no-files-found: warn + + # ───────────────────────────────────────────── + # Stage 4: Publish to GitHub Releases + # ───────────────────────────────────────────── + release: + name: Publish Release + runs-on: ubuntu-latest + needs: [build-macos, build-windows, build-linux] + if: startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true' + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: false + + - name: Collect release files + run: | + mkdir -p release-files + find artifacts -type f \( \ + -name "*.dmg" -o -name "*.zip" -o -name "*.exe" \ + -o -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" \ + -o -name "*.snap" -o -name "*.blockmap" \ + -o -name "latest*.yml" \ + \) -exec cp {} release-files/ \; + ls -lh release-files/ + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + draft: true + generate_release_notes: true + files: release-files/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index c9dda0a6..8167aea1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,15 @@ win-unpacked test-results/ playwright-report/ +# Signing certificates (NEVER commit these) +*.p12 +*.cer +*.pem +*.key +*.p8 +*.provisionprofile +*.mobileprovision + # Environment files .env .env.local diff --git a/assets/entitlements.mac.plist b/assets/entitlements.mac.plist new file mode 100644 index 00000000..ea3e9d2c --- /dev/null +++ b/assets/entitlements.mac.plist @@ -0,0 +1,18 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.files.user-selected.read-write + + + diff --git a/electron-builder.yml b/electron-builder.yml index f2c4cc6f..e096ac5b 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -8,23 +8,78 @@ files: - package.json asar: true icon: assets/icon.png + mac: category: public.app-category.developer-tools icon: assets/icon.icns target: - - dmg - - zip + - target: dmg + arch: + - x64 + - arm64 + - target: zip + arch: + - x64 + - arm64 hardenedRuntime: true + gatekeeperAssess: false + entitlements: assets/entitlements.mac.plist + entitlementsInherit: assets/entitlements.mac.plist + notarize: false + win: icon: assets/icon.ico target: - - nsis - - portable + - target: nsis + arch: + - x64 + - arm64 + - target: portable + arch: + - x64 + linux: icon: assets/icon.png target: - - AppImage + - target: AppImage + arch: + - x64 + - arm64 + - target: deb + arch: + - x64 + - arm64 + - target: rpm + arch: + - x64 + - target: snap + arch: + - x64 + category: Development + nsis: oneClick: false perMachine: false allowToChangeInstallationDirectory: true + +deb: + depends: + - libgtk-3-0 + - libnotify4 + - libnss3 + - libxss1 + - libxtst6 + - xdg-utils + - libatspi2.0-0 + - libuuid1 + - libsecret-1-0 + +snap: + plugs: + - default + - removable-media + +publish: + provider: github + owner: ${env.GH_REPO_OWNER} + repo: ${env.GH_REPO_NAME} diff --git a/package.json b/package.json index 48afb1e2..c0d6391b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-company", - "version": "0.4.80", + "version": "0.4.90", "private": true, "type": "module", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", diff --git a/src/main/api-server.ts b/src/main/api-server.ts index 9a94f9ab..61c274f9 100644 --- a/src/main/api-server.ts +++ b/src/main/api-server.ts @@ -390,6 +390,47 @@ export class AgentApiServer { this.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" }); return; } + + // Deliverable review gate: intercept agent trying to advance a flagged task to "done" + let deliverableReviewIntercepted = false; + if (update.status === "done" && task.requiresUserReview) { + try { + const importantTypes = ["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"]; + const placeholders = importantTypes.map(() => "?").join(", "); + // Scope to documents authored by the task's agent, created during this task's lifetime + const docs = this.db.queryAll<{ id: string; type: string; title: string }>( + `select id, type, title from documents where company_id = ? and type in (${placeholders}) + and (author_agent_id = ? or created_at >= (select created_at from tasks where id = ?)) + order by created_at desc limit 10`, + task.companyId, ...importantTypes, task.assigneeAgentId ?? "", task.id, + ); + const reviewerAgent = this.db.getAgent(token.agentId); + const docTitles = docs.map((d) => d.title).join(", "); + this.db.requestApproval({ + companyId: task.companyId, + relatedTaskId: task.id, + requestedByAgentId: token.agentId, + type: "deliverable_review", + payloadSummary: `Review deliverable: ${docTitles || task.title}`.slice(0, 280), + impactSummary: `${reviewerAgent.name} reviewed and approved. Awaiting your final review.`.slice(0, 400), + payloadJson: JSON.stringify({ documentIds: docs.map((d) => d.id), documents: docs }), + }); + this.db.setTaskRequiresUserReview(task.id, false); + // Pause the producing agent's heartbeat until user reviews + if (task.assigneeAgentId) { + try { + const prodAgent = this.db.getAgent(task.assigneeAgentId); + if (prodAgent.heartbeatEnabled) { + this.db.setHeartbeat(task.assigneeAgentId, false, prodAgent.heartbeatIntervalSec || 120); + } + } catch { /* best effort */ } + } + this.db.addComment({ companyId: task.companyId, taskId: task.id, authorAgentId: null, authorName: "System", body: "Deliverable submitted for board review." }); + update.status = "in_review"; // Override: keep in review, don't advance to done + deliverableReviewIntercepted = true; + } catch { /* deliverable review interception is best-effort; fall through to normal flow */ } + } + const input: Record = { id: task.id, companyId: task.companyId, @@ -1393,10 +1434,12 @@ export class AgentApiServer { this.json(res, 400, { error: "Goal does not belong to the current company" }); return; } + const docType = String(data.type ?? "knowledge_article"); + const docTitle = String(data.title ?? "Untitled"); const id = this.db.saveDocument({ companyId: token.companyId, - type: String(data.type ?? "knowledge_article"), - title: String(data.title ?? "Untitled"), + type: docType, + title: docTitle, content: String(data.content ?? ""), authorAgentId: token.agentId, reviewerAgentId, @@ -1412,6 +1455,37 @@ export class AgentApiServer { if (reviewerAgentId) { this.onWakeAgent(reviewerAgentId, token.companyId, "assignment"); } + + // Deliverable review gate: check at document creation time (most reliable) + const IMPORTANT_DOC_TYPES = new Set(["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"]); + if (IMPORTANT_DOC_TYPES.has(docType)) { + try { + const company = this.db.listSnapshot().companies.find((c) => c.id === token.companyId); + if (company?.reviewDeliverables) { + // Check if there's already a pending deliverable_review for this company + if (!this.db.hasPendingDeliverableReviews(token.companyId)) { + this.db.requestApproval({ + companyId: token.companyId, + relatedTaskId: null, + requestedByAgentId: token.agentId, + type: "deliverable_review", + payloadSummary: `Review deliverable: ${docTitle}`.slice(0, 280), + impactSummary: `New ${docType.replaceAll("_", " ")} created. Awaiting your review before workflow continues.`.slice(0, 400), + payloadJson: JSON.stringify({ documentIds: [id], documents: [{ id, type: docType, title: docTitle }] }), + }); + // Pause the producing agent's heartbeat + try { + const agent = this.db.getAgent(token.agentId); + if (agent.heartbeatEnabled) { + this.db.setHeartbeat(token.agentId, false, agent.heartbeatIntervalSec || 120); + } + } catch { /* best effort */ } + this.onDomainChanged(); + } + } + } catch { /* deliverable review gate is best-effort */ } + } + this.json(res, 201, { id }); }); diff --git a/src/main/app-lifecycle-service.ts b/src/main/app-lifecycle-service.ts index 2013f978..fe741783 100644 --- a/src/main/app-lifecycle-service.ts +++ b/src/main/app-lifecycle-service.ts @@ -10,7 +10,7 @@ interface AppLifecycleDependencies { export function createAppLifecycleService({ getMainWindow, ensureWindow, - shouldQuit: _shouldQuit, + shouldQuit, onBeforeQuit, }: AppLifecycleDependencies) { function register(hasSingleInstanceLock: boolean) { @@ -44,9 +44,9 @@ export function createAppLifecycleService({ }); app.on("window-all-closed", () => { - // On macOS, keep the app running when all windows are closed (standard behavior). - // On Windows and Linux, quit the app. - if (process.platform !== "darwin") { + // On macOS, keep the app running when all windows are closed (standard behavior) + // UNLESS the user explicitly chose Quit (before-quit already fired). + if (process.platform !== "darwin" || shouldQuit()) { app.quit(); } }); diff --git a/src/main/approval-policy.ts b/src/main/approval-policy.ts index f4b16594..d25f3ec0 100644 --- a/src/main/approval-policy.ts +++ b/src/main/approval-policy.ts @@ -11,10 +11,19 @@ export function shouldAutoApproveApproval( return false; } - return db + const company = db .listSnapshot() .companies - .some((company) => company.id === companyId && company.autoApproveHires); + .find((c) => c.id === companyId); + + if (!company?.autoApproveHires) return false; + + // When reviewDeliverables is ON, CEO strategy needs human review — don't auto-approve + if (approvalType === "approve_ceo_strategy" && company.reviewDeliverables) { + return false; + } + + return true; } export function resolveApprovalDecision({ diff --git a/src/main/automation-service.ts b/src/main/automation-service.ts index 0da9b16a..7ebea51c 100644 --- a/src/main/automation-service.ts +++ b/src/main/automation-service.ts @@ -10,6 +10,12 @@ import type { import { dispatchAgentMessage } from "./message-dispatch"; import type { NotificationOptions } from "./notification-service"; +/** Document types considered important enough to gate behind user review when reviewDeliverables is enabled. */ +const IMPORTANT_DOCUMENT_TYPES = new Set([ + "prd", "technical_spec", "design_doc", "project_brief", "proposal", + "budget_proposal", "architecture_decision", "contract", "sop", +]); + const WORKFLOW_ACTIONS: ReadonlySet = new Set([ "assign_task", "create_task", @@ -801,6 +807,57 @@ export function createAutomationService({ return false; } + /** Returns important documents created during this run (matching IMPORTANT_DOCUMENT_TYPES). */ + function findImportantDocsForRun(runId: string, companyId: string): Array<{ id: string; type: string; title: string }> { + try { + const placeholders = [...IMPORTANT_DOCUMENT_TYPES].map(() => "?").join(", "); + return db.queryAll<{ id: string; type: string; title: string }>( + `select id, type, title from documents where company_id = ? and type in (${placeholders}) and created_at >= (select started_at from runs where id = ?)`, + companyId, ...IMPORTANT_DOCUMENT_TYPES, runId, + ); + } catch { return []; } + } + + /** Creates a deliverable_review approval and fires OS notification. */ + function createDeliverableReviewApproval( + companyId: string, + taskId: string, + agentId: string, + docs: Array<{ id: string; type: string; title: string }>, + reviewerNote?: string, + ): string { + const docTitles = docs.map((d) => d.title).join(", "); + const approvalId = db.requestApproval({ + companyId, + relatedTaskId: taskId, + requestedByAgentId: agentId, + type: "deliverable_review", + payloadSummary: `Review deliverable: ${docTitles}`.slice(0, 280), + impactSummary: reviewerNote ?? "Agent completed work with important deliverables. Awaiting your final review.", + payloadJson: JSON.stringify({ documentIds: docs.map((d) => d.id), documents: docs }), + }); + db.setTaskRequiresUserReview(taskId, false); + // Pause the producing agent's heartbeat until the user reviews + try { + const agent = db.getAgent(agentId); + if (agent.heartbeatEnabled) { + db.setHeartbeat(agentId, false, agent.heartbeatIntervalSec || 120); + logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — awaiting user review.`); + } + } catch { /* best effort */ } + try { + notify({ + title: "Deliverable Ready for Review", + body: `${docTitles}`.slice(0, 200), + urgency: "informational", + navigation: { section: "approvals", entityId: approvalId }, + batchKey: "deliverable_review", + }); + } catch { /* best effort */ } + publishDomainChanged(); + return approvalId; + } + function processRunCompletion(run: RunRecord, event: RunFinishedEvent) { let task: ReturnType | null = null; try { @@ -841,6 +898,27 @@ export function createAutomationService({ db.advanceTaskStatus(task.id, "in_review", "Run completed with deliverable. Awaiting review."); handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review"); effectiveStatus = "in_review"; + // Check if deliverable review gate should activate + try { + const company = db.listSnapshot().companies.find((c) => c.id === run.companyId); + if (company?.reviewDeliverables) { + const importantDocs = findImportantDocsForRun(run.id, run.companyId); + if (importantDocs.length > 0) { + db.setTaskRequiresUserReview(task.id, true); + const agent = db.getAgent(run.agentId); + // Pause heartbeat immediately — agent should not run while deliverable awaits review + if (agent.heartbeatEnabled) { + db.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120); + logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`); + } + // If agent has no manager (is CEO), create approval immediately + const chain = db.getChainOfCommand(agent.id); + if (chain.length === 0) { + createDeliverableReviewApproval(run.companyId, task.id, run.agentId, importantDocs); + } + } + } + } catch { /* deliverable review gate is best-effort */ } publishDomainChanged(); } } else { @@ -848,6 +926,25 @@ export function createAutomationService({ db.advanceTaskStatus(task.id, "in_review", "Run completed successfully. Awaiting review."); handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review"); effectiveStatus = "in_review"; + // Check if deliverable review gate should activate for code/general tasks too + try { + const company = db.listSnapshot().companies.find((c) => c.id === run.companyId); + if (company?.reviewDeliverables) { + const importantDocs = findImportantDocsForRun(run.id, run.companyId); + if (importantDocs.length > 0) { + db.setTaskRequiresUserReview(task.id, true); + const agent = db.getAgent(run.agentId); + if (agent.heartbeatEnabled) { + db.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120); + logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`); + } + const chain = db.getChainOfCommand(agent.id); + if (chain.length === 0) { + createDeliverableReviewApproval(run.companyId, task.id, run.agentId, importantDocs); + } + } + } + } catch { /* deliverable review gate is best-effort */ } publishDomainChanged(); } } @@ -976,6 +1073,42 @@ export function createAutomationService({ } } + if (approval.type === "deliverable_review" && approval.relatedTaskId) { + try { + const task = db.getTask(approval.relatedTaskId); + // Re-enable heartbeat for the producing agent now that the user has decided + if (task.assigneeAgentId) { + try { + const agent = db.getAgent(task.assigneeAgentId); + if (!agent.heartbeatEnabled) { + db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120); + logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`); + } + } catch { /* best effort */ } + } + if (decision === "approved") { + db.advanceTaskStatus(task.id, "done", "Board approved deliverable."); + handleTaskStatusChange(task.id, companyId, task.status, "done"); + db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." }); + publishDomainChanged(); + } else if (decision === "rejected") { + db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`); + handleTaskStatusChange(task.id, companyId, task.status, "in_progress"); + if (task.assigneeAgentId) { + wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed"); + } + publishDomainChanged(); + } else if (decision === "revision_requested") { + if (task.assigneeAgentId) { + wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed"); + } + publishDomainChanged(); + } + } catch { + // Deliverable review follow-up is best-effort. + } + } + const browserAction = db.getBrowserActionByApprovalId(approvalId, companyId); if (browserAction) { if (decision === "approved") { diff --git a/src/main/database.ts b/src/main/database.ts index 9a934214..7dc9f034 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -528,8 +528,8 @@ export class AppDatabase { secretBackend: this.vault.getBackendLabel(), }, companies: this.readAll( - "select id, name, description, status, auto_approve_hires as autoApproveHires, created_at as createdAt, updated_at as updatedAt from companies order by created_at asc", - ).map((record) => ({ ...record, autoApproveHires: toBoolean(record.autoApproveHires) })) as CompanyRecord[], + "select id, name, description, status, auto_approve_hires as autoApproveHires, review_deliverables as reviewDeliverables, created_at as createdAt, updated_at as updatedAt from companies order by created_at asc", + ).map((record) => ({ ...record, autoApproveHires: toBoolean(record.autoApproveHires), reviewDeliverables: toBoolean(record.reviewDeliverables) })) as CompanyRecord[], connectors, workspaces: this.readAll( `select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces${companyWhere} order by created_at desc`, @@ -548,9 +548,9 @@ export class AppDatabase { ...companyArgs, ), tasks: this.readAll( - `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc`, + `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc`, ...companyArgs, - ), + ).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview) })) as TaskRecord[], approvals: this.readAll( `select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals${companyWhere} order by created_at desc`, ...companyArgs, @@ -675,19 +675,20 @@ export class AppDatabase { return snapshot; } - saveCompany(input: { id?: string | null; name: string; description: string; status: string; autoApproveHires?: boolean }) { + saveCompany(input: { id?: string | null; name: string; description: string; status: string; autoApproveHires?: boolean; reviewDeliverables?: boolean }) { const id = ensureId(input.id ?? null); const ts = nowIso(); const autoApprove = input.autoApproveHires ? 1 : 0; + const reviewDeliverables = input.reviewDeliverables ? 1 : 0; const existing = this.db.prepare("select id from companies where id = ?").get(id) as { id: string } | undefined; if (existing) { this.db - .prepare("update companies set name = ?, description = ?, status = ?, auto_approve_hires = ?, updated_at = ? where id = ?") - .run(input.name, input.description, input.status, autoApprove, ts, id); + .prepare("update companies set name = ?, description = ?, status = ?, auto_approve_hires = ?, review_deliverables = ?, updated_at = ? where id = ?") + .run(input.name, input.description, input.status, autoApprove, reviewDeliverables, ts, id); } else { this.db - .prepare("insert into companies (id, name, description, status, auto_approve_hires, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?)") - .run(id, input.name, input.description, input.status, autoApprove, ts, ts); + .prepare("insert into companies (id, name, description, status, auto_approve_hires, review_deliverables, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)") + .run(id, input.name, input.description, input.status, autoApprove, reviewDeliverables, ts, ts); if (!this.getCurrentCompanyId()) { this.setCurrentCompany(id); } @@ -1098,10 +1099,11 @@ export class AppDatabase { getTask(id: string): TaskRecord { const row = this.db .prepare( - "select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, created_at as createdAt, updated_at as updatedAt from tasks where id = ?", + "select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, created_at as createdAt, updated_at as updatedAt from tasks where id = ?", ) .get(id) as TaskRecord | undefined; if (!row) throw new Error(`Task not found: ${id}`); + row.requiresUserReview = Boolean(row.requiresUserReview); return row; } @@ -2180,7 +2182,9 @@ export class AppDatabase { addColumnIfMissing("alter table agents add column heartbeat_interval_sec integer not null default 120"); addColumnIfMissing("alter table agents add column last_heartbeat_at text"); addColumnIfMissing("alter table companies add column auto_approve_hires integer not null default 0"); + addColumnIfMissing("alter table companies add column review_deliverables integer not null default 0"); addColumnIfMissing("alter table tasks add column task_type text not null default 'general'"); + addColumnIfMissing("alter table tasks add column requires_user_review integer not null default 0"); addColumnIfMissing("alter table tasks add column retry_count integer not null default 0"); addColumnIfMissing("alter table tasks add column last_failure_at text"); this.db.exec(` @@ -2275,6 +2279,7 @@ export class AppDatabase { or (type = 'dangerous_command' and ${taskCondition}) or (type = 'secret_access' and ${taskCondition}) or (type = 'approve_ceo_strategy' and ${taskCondition}) + or (type = 'deliverable_review' and (requested_by_agent_id = ? or (related_task_id is not null and ${taskCondition}))) ) order by created_at asc limit 1`, companyId, @@ -2282,6 +2287,8 @@ export class AppDatabase { ...taskParams, ...taskParams, ...taskParams, + agentId, + ...taskParams, ); return rows.length > 0 ? rows[0] : null; } @@ -2802,6 +2809,43 @@ export class AppDatabase { return { previousStatus }; } + setTaskRequiresUserReview(taskId: string, value: boolean) { + const ts = nowIso(); + this.db.prepare("update tasks set requires_user_review = ?, updated_at = ? where id = ?").run(value ? 1 : 0, ts, taskId); + } + + /** Returns true if the company has any pending deliverable_review approvals or tasks awaiting review. */ + hasPendingDeliverableReviews(companyId: string): boolean { + const approval = this.db.prepare( + "select 1 from approvals where company_id = ? and type = 'deliverable_review' and state = 'pending' limit 1", + ).get(companyId); + if (approval) return true; + const flaggedTask = this.db.prepare( + "select 1 from tasks where company_id = ? and requires_user_review = 1 limit 1", + ).get(companyId); + return !!flaggedTask; + } + + /** Returns true if this specific agent has tasks with pending deliverable_review approvals. */ + hasAgentPendingDeliverableReview(agentId: string, companyId: string): boolean { + const row = this.db.prepare( + `select 1 from approvals a + join tasks t on a.related_task_id = t.id + where a.company_id = ? and a.type = 'deliverable_review' and a.state = 'pending' + and t.assignee_agent_id = ? + limit 1`, + ).get(companyId, agentId); + return !!row; + } + + /** Returns true if agent has any tasks flagged requires_user_review (before approval is created). */ + hasAgentTaskAwaitingReview(agentId: string, companyId: string): boolean { + const row = this.db.prepare( + "select 1 from tasks where assignee_agent_id = ? and company_id = ? and requires_user_review = 1 limit 1", + ).get(agentId, companyId); + return !!row; + } + private incrementActiveSprintVelocity(companyId: string, delta: number) { const ts = nowIso(); const today = ts.slice(0, 10); diff --git a/src/main/ipc-core-work-graph-handlers.ts b/src/main/ipc-core-work-graph-handlers.ts index a55de05d..9ac2a941 100644 --- a/src/main/ipc-core-work-graph-handlers.ts +++ b/src/main/ipc-core-work-graph-handlers.ts @@ -346,6 +346,10 @@ export function registerCoreWorkGraphHandlers({ if (previousStatus && previousStatus !== parsed.status) { handleTaskStatusChange(id, parsed.companyId, previousStatus, parsed.status); } + // User (board) manually advancing a flagged task to done: clear the review flag + if (parsed.status === "done") { + try { db.setTaskRequiresUserReview(id, false); } catch { /* best effort */ } + } // Wake new assignee when task is reassigned (matches api-server.ts behavior) if (parsed.assigneeAgentId && parsed.assigneeAgentId !== previousAssigneeAgentId) { wakeAgentIfPossible(parsed.assigneeAgentId, parsed.companyId, "assignment"); diff --git a/src/main/onboarding-service.ts b/src/main/onboarding-service.ts index 5b60aeb0..a5a2ae0e 100644 --- a/src/main/onboarding-service.ts +++ b/src/main/onboarding-service.ts @@ -17,6 +17,7 @@ interface BootstrapInput { companyDescription: string; workspacePath: string; autoApproveHires: boolean; + reviewDeliverables?: boolean; goalTitle: string; goalDescription: string; connectorId?: ConnectorId; @@ -188,6 +189,7 @@ export function createOnboardingService({ description: input.companyDescription.trim(), status: "active", autoApproveHires: input.autoApproveHires, + reviewDeliverables: input.reviewDeliverables, }); const workspaceId = createDefaultWorkspace(companyId, input.workspacePath); diff --git a/src/main/orchestration-service.ts b/src/main/orchestration-service.ts index 65c665a5..8677fc58 100644 --- a/src/main/orchestration-service.ts +++ b/src/main/orchestration-service.ts @@ -335,6 +335,20 @@ export function createOrchestrationService({ return null; } + // Block heartbeat when agent has tasks awaiting user deliverable review + // Check both: pending approval (gate created) and requires_user_review flag (gap before approval) + if (db.hasAgentPendingDeliverableReview(agentId, companyId) || db.hasAgentTaskAwaitingReview(agentId, companyId)) { + logger.info(`[heartbeat] Skipping ${agent.name} — awaiting user deliverable review.`); + return null; + } + + // Block CEO heartbeat when company has any pending deliverable reviews + // CEO should not create new work while the user hasn't reviewed existing deliverables + if (!agent.reportsTo && db.hasPendingDeliverableReviews(companyId)) { + logger.info(`[heartbeat] Skipping CEO ${agent.name} — company has pending deliverable reviews.`); + return null; + } + const assignedTasks = db.listAgentTasks(agentId, companyId, ["todo", "in_progress", "blocked"]); const isTimer = trigger === "timer"; const candidateTasks = isTimer diff --git a/src/main/service-graph.ts b/src/main/service-graph.ts index a293ea7b..f8170575 100644 --- a/src/main/service-graph.ts +++ b/src/main/service-graph.ts @@ -1,3 +1,4 @@ +import { app } from "electron"; import type { AgentApiServer } from "./api-server"; import type { AppDatabase } from "./database"; import { BrowserManager } from "./browser-manager"; @@ -225,6 +226,9 @@ export function createServiceGraph({ updaterService = null; appLifecycleService = null; db.close(); + // Force exit after grace period — covers all platforms (macOS Dock, Windows tray, Linux) + // Worker child processes (claude/codex CLI) may keep the event loop alive indefinitely + setTimeout(() => { app.exit(0); }, 3000); }, }); } diff --git a/src/main/worker-service.ts b/src/main/worker-service.ts index a56044d6..f5976b22 100644 --- a/src/main/worker-service.ts +++ b/src/main/worker-service.ts @@ -426,8 +426,15 @@ export function createWorkerService({ } function destroy() { - worker?.kill(); - worker = null; + if (worker) { + worker.kill(); + // Force-kill after 2s if SIGTERM didn't work (child CLI processes may keep it alive) + const ref = worker; + setTimeout(() => { + try { ref.kill(); } catch { /* already dead */ } + }, 2000); + worker = null; + } } return { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3255bbe1..5fe41304 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1087,6 +1087,7 @@ export function App() { approval={selectedApproval} agents={companyAgents} tasks={tasks} + documents={companyDocuments} companyId={currentCompany!.id} onDecide={async (state, note) => { unwrap(await window.agentCompany.decideApproval({ companyId: currentCompany!.id, approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` })); diff --git a/src/renderer/components/CompanyOnboarding/CompanyStep.tsx b/src/renderer/components/CompanyOnboarding/CompanyStep.tsx index 9c9430b8..76142a69 100644 --- a/src/renderer/components/CompanyOnboarding/CompanyStep.tsx +++ b/src/renderer/components/CompanyOnboarding/CompanyStep.tsx @@ -1,5 +1,5 @@ import { motion } from "framer-motion"; -import { Buildings, FolderOpen } from "@phosphor-icons/react"; +import { Buildings, FolderOpen, Lightning, UserCircleCheck } from "@phosphor-icons/react"; import type { ProfileSnapshot } from "@shared/types"; import type { AppLocale } from "@shared/locale"; import { ActionButton, Input, Select, TextArea } from "../ui"; @@ -16,6 +16,8 @@ interface CompanyStepProps { setWorkspacePath: (v: string) => void; autoApproveHires: boolean; setAutoApproveHires: (v: boolean) => void; + reviewDeliverables: boolean; + setReviewDeliverables: (v: boolean) => void; onPickDirectory: () => void; onContinue: () => void; onBack: () => void; @@ -38,6 +40,8 @@ export function CompanyStep({ setWorkspacePath, autoApproveHires, setAutoApproveHires, + reviewDeliverables, + setReviewDeliverables, onPickDirectory, onContinue, onBack, @@ -111,21 +115,28 @@ export function CompanyStep({ {t("onboarding.workspaceHelp")} -
- +
+
{t("onboarding.automationMode")}
+
+ + +
diff --git a/src/renderer/components/CompanyOnboarding/OnboardingWizard.tsx b/src/renderer/components/CompanyOnboarding/OnboardingWizard.tsx index 4ca63aeb..88fc3c9e 100644 --- a/src/renderer/components/CompanyOnboarding/OnboardingWizard.tsx +++ b/src/renderer/components/CompanyOnboarding/OnboardingWizard.tsx @@ -37,6 +37,7 @@ export function CompanyOnboarding({ const [companyDescription, setCompanyDescription] = useState(""); const [workspacePath, setWorkspacePath] = useState(""); const [autoApproveHires, setAutoApproveHires] = useState(true); + const [reviewDeliverables, setReviewDeliverables] = useState(false); const [goalTitle, setGoalTitle] = useState(""); const [goalDescription, setGoalDescription] = useState(""); const [loading, setLoading] = useState(false); @@ -123,6 +124,7 @@ export function CompanyOnboarding({ companyDescription: companyDescription.trim(), workspacePath: workspacePath.trim(), autoApproveHires, + reviewDeliverables, goalTitle: goalTitle.trim(), goalDescription: goalDescription.trim(), connectorId: selectedConnector?.id, @@ -131,6 +133,7 @@ export function CompanyOnboarding({ setStep("launch"); }, [ autoApproveHires, + reviewDeliverables, companyDescription, companyName, goalDescription, @@ -231,6 +234,8 @@ export function CompanyOnboarding({ setWorkspacePath={setWorkspacePath} autoApproveHires={autoApproveHires} setAutoApproveHires={setAutoApproveHires} + reviewDeliverables={reviewDeliverables} + setReviewDeliverables={setReviewDeliverables} onPickDirectory={() => void pickDirectory()} onContinue={() => void continueToMission()} onBack={() => setStep("language")} diff --git a/src/renderer/components/execution/ApprovalInspector.tsx b/src/renderer/components/execution/ApprovalInspector.tsx index 22c5fd33..2983050f 100644 --- a/src/renderer/components/execution/ApprovalInspector.tsx +++ b/src/renderer/components/execution/ApprovalInspector.tsx @@ -1,6 +1,7 @@ import { useState } from "react" import { Briefcase, CheckCircle, Clock, ClockCounterClockwise, Robot, Trash, XCircle } from "@phosphor-icons/react" -import type { AgentRecord, ApprovalRecord, TaskRecord } from "@shared/types" +import { CaretDown, CaretUp } from "@phosphor-icons/react" +import type { AgentRecord, ApprovalRecord, DocumentRecord, TaskRecord } from "@shared/types" import { useT } from "../../i18n" import { formatMoney, formatTime } from "../../lib/formatters" import { unwrap } from "../../lib/desktop" @@ -14,11 +15,12 @@ function getTypeBadgeClass(type: string): string { if (type === "approve_ceo_strategy") return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]" if (type === "secret_access") return "bg-[color:var(--danger-soft)] text-[color:var(--danger)]" if (type === "social_post") return "bg-[color:var(--accent-soft)] text-[color:var(--accent)]" + if (type === "deliverable_review") return "bg-[color:var(--accent-soft)] text-[color:var(--accent)]" return "bg-[color:var(--panel-soft)] text-[color:var(--muted)]" } -export function ApprovalInspector({ approval, agents, tasks, companyId, onDecide, onDeleted }: { - approval: ApprovalRecord; agents: AgentRecord[]; tasks: TaskRecord[]; companyId: string +export function ApprovalInspector({ approval, agents, tasks, documents, companyId, onDecide, onDeleted }: { + approval: ApprovalRecord; agents: AgentRecord[]; tasks: TaskRecord[]; documents?: DocumentRecord[]; companyId: string onDecide: (state: "approved" | "rejected" | "revision_requested", note?: string) => Promise; onDeleted: () => Promise }) { const t = useT() @@ -112,6 +114,7 @@ export function ApprovalInspector({ approval, agents, tasks, companyId, onDecide {mgr ?
Reports to: {mgr.name}
: null} ) })() : null} {approval.type === "approve_ceo_strategy" ?
Organization Strategy
{approval.payloadSummary}
: null} + {approval.type === "deliverable_review" ? : null} {isSocialPost && approval.payloadJson ? ( ) } + +function DeliverableReviewPanel({ approval, documents, agents }: { approval: ApprovalRecord; documents?: DocumentRecord[]; agents: AgentRecord[] }) { + const payload = parseApprovalPayloadJson(approval) + const docRefs = Array.isArray(payload?.documents) ? payload.documents as Array<{ id: string; type: string; title: string }> : [] + const docIds = Array.isArray(payload?.documentIds) ? payload.documentIds as string[] : docRefs.map((d) => d.id) + const fullDocs = documents ? docIds.map((id) => documents.find((d) => d.id === id)).filter(Boolean) as DocumentRecord[] : [] + const [expandedDocId, setExpandedDocId] = useState(fullDocs.length === 1 ? fullDocs[0]!.id : null) + + return ( +
+
Deliverable Review
+ {fullDocs.length > 0 ? fullDocs.map((doc) => { + const isExpanded = expandedDocId === doc.id + const author = doc.authorAgentId ? agents.find((a) => a.id === doc.authorAgentId) : null + return ( +
+ + {isExpanded ? ( +
+ {author ?
Author: {author.name} ({author.role})
: null} +
{doc.content || "No content."}
+
+ ) : null} +
+ ) + }) : docRefs.length > 0 ? docRefs.map((doc) => ( +
+ {doc.type.replaceAll("_", " ")} + {doc.title} +
+ )) :
{approval.payloadSummary}
} + {approval.impactSummary ?
{approval.impactSummary}
: null} +
+ ) +} diff --git a/src/renderer/components/execution/SettingsPane.tsx b/src/renderer/components/execution/SettingsPane.tsx index ec486e20..b883825d 100644 --- a/src/renderer/components/execution/SettingsPane.tsx +++ b/src/renderer/components/execution/SettingsPane.tsx @@ -37,7 +37,10 @@ export function SettingsPane({ snapshot, secrets, companyId, onSaved }: { snapsh {(() => { const company = snapshot.companies.find((c) => c.id === companyId); if (!company) return null; return (
{t("exec.companyAutonomy")}
-
) })()} +
+
+
+
) })()}
{t("exec.actions")}
void handleSaveSettings()} icon={CheckCircle} loading={loadingAction === "save"} /> void handleCheckForUpdates()} icon={Download} loading={loadingAction === "check"} /> void handleBackupProfile()} icon={ClockCounterClockwise} loading={loadingAction === "backup"} /> void handleRestoreProfile()} icon={ArrowClockwise} loading={loadingAction === "restore"} />
{t("exec.secretBackend")}
{snapshot.backend.secretBackend}
{t("exec.secrets")} ({secrets.length})
{secrets.length === 0 ? :
{secrets.map((s) => )}
{t("exec.secretName")}{t("exec.secretDescription")}{t("exec.secretBackendCol")}{t("exec.secretUpdated")}
{s.name}{s.description || "\u2014"}{s.backend}{formatTime(s.updatedAt)} setConfirmDeleteSecretId(s.id)} icon={Trash} tone="danger" />
}
diff --git a/src/renderer/i18n/en.ts b/src/renderer/i18n/en.ts index 93658ee8..5bb1b77a 100644 --- a/src/renderer/i18n/en.ts +++ b/src/renderer/i18n/en.ts @@ -73,6 +73,13 @@ export const en: Record = { "onboarding.autoApprove": "Auto-approve hire and strategy approvals", "onboarding.autoApproveLabel": "Auto-approve hire and strategy approvals", "onboarding.autoApproveHelp": "Skips board review for hiring and strategy changes.", + "onboarding.reviewDeliverablesLabel": "Review important deliverables", + "onboarding.reviewDeliverablesHelp": "Pause workflow for your review on PRDs, design docs, and key documents.", + "onboarding.automationMode": "Automation mode", + "onboarding.modeFullAuto": "Full automation", + "onboarding.modeFullAutoDesc": "Agents run end-to-end without pausing. Best for hands-off operation.", + "onboarding.modeHumanReview": "Human in the loop", + "onboarding.modeHumanReviewDesc": "Pause for your review on PRDs, design docs, and other key deliverables.", "onboarding.continue": "Continue", "onboarding.back": "Back", "onboarding.goal": "Goal", @@ -426,6 +433,8 @@ export const en: Record = { "exec.companyAutonomy": "Company autonomy", "exec.autoApproveLabel": "Auto-approve hire and strategy approvals", "exec.autoApproveDesc": "Auto-approve hiring and CEO strategy decisions without board review.", + "exec.reviewDeliverablesLabel": "Auto-approve important deliverables", + "exec.reviewDeliverablesDesc": "Skip board review when agents produce PRDs, design docs, and other key documents.", "exec.actions": "Actions", "exec.saveSettings": "Save settings", "exec.checkForUpdates": "Check for updates", diff --git a/src/renderer/i18n/zh.ts b/src/renderer/i18n/zh.ts index ec9c0912..9a58dca0 100644 --- a/src/renderer/i18n/zh.ts +++ b/src/renderer/i18n/zh.ts @@ -73,6 +73,13 @@ export const zh: Record = { "onboarding.autoApprove": "自动批准招聘和战略审批", "onboarding.autoApproveLabel": "自动批准招聘和战略审批", "onboarding.autoApproveHelp": "跳过招聘和战略变更的董事会审批。", + "onboarding.reviewDeliverablesLabel": "重要产出物人工审核", + "onboarding.reviewDeliverablesHelp": "当 Agent 产出 PRD、设计文档等重要产出物时暂停流程,等待您审核。", + "onboarding.automationMode": "自动化模式", + "onboarding.modeFullAuto": "全自动", + "onboarding.modeFullAutoDesc": "Agent 全程自动运行,无需人工干预。适合放手运营。", + "onboarding.modeHumanReview": "人工审核", + "onboarding.modeHumanReviewDesc": "当产出 PRD、设计文档等重要产出物时暂停,等待您审核后继续。", "onboarding.continue": "继续", "onboarding.back": "返回", "onboarding.goal": "目标", @@ -426,6 +433,8 @@ export const zh: Record = { "exec.companyAutonomy": "公司自治", "exec.autoApproveLabel": "自动批准招聘和战略审批", "exec.autoApproveDesc": "自动批准招聘和 CEO 战略决策,无需董事会审批。", + "exec.reviewDeliverablesLabel": "自动通过重要产出物", + "exec.reviewDeliverablesDesc": "自动通过 Agent 产出的 PRD、设计文档等重要产出物,无需人工审核。", "exec.actions": "操作", "exec.saveSettings": "保存设置", "exec.checkForUpdates": "检查更新", diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index b2648bf6..5ff2552a 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -86,6 +86,7 @@ export const companyInputSchema = z.object({ description: z.string().max(500).default(""), status: z.enum(["active", "paused", "archived"]).default("active"), autoApproveHires: z.boolean().default(false), + reviewDeliverables: z.boolean().default(false), }); export const connectorInputSchema = z.object({ @@ -180,7 +181,7 @@ export const approvalInputSchema = z.object({ relatedTaskId: optionalId, requestedByAgentId: optionalId, relatedAgentId: optionalId, - type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access", "budget_request", "promotion", "termination", "vendor_approval", "document_review", "production_deploy", "incident_escalation", "social_post"]), + type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access", "budget_request", "promotion", "termination", "vendor_approval", "document_review", "production_deploy", "incident_escalation", "social_post", "deliverable_review"]), payloadSummary: z.string().min(2).max(280), impactSummary: z.string().min(2).max(400), payloadJson: z.string().nullable().optional(), @@ -287,6 +288,7 @@ export const bootstrapOnboardingSchema = z.object({ companyDescription: z.string().max(500).default(""), workspacePath: z.string().default(""), autoApproveHires: z.boolean().default(true), + reviewDeliverables: z.boolean().default(false), goalTitle: z.string().default(""), goalDescription: z.string().default(""), connectorId: z.enum(["codex_local", "claude_local", "gemini_local"]).optional(), diff --git a/src/shared/types.ts b/src/shared/types.ts index 04c00ad4..c3cefc6a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -65,7 +65,8 @@ export type ApprovalType = | "document_review" | "production_deploy" | "incident_escalation" - | "social_post"; + | "social_post" + | "deliverable_review"; export type RunStatus = | "queued" | "running" @@ -89,6 +90,7 @@ export interface CompanyRecord { description: string; status: CompanyStatus; autoApproveHires: boolean; + reviewDeliverables: boolean; createdAt: string; updatedAt: string; } @@ -202,6 +204,7 @@ export interface TaskRecord { sessionJson: string | null; sessionDisplayId: string | null; costUsd: number; + requiresUserReview: boolean; createdAt: string; updatedAt: string; }