diff --git a/CHANGELOG.md b/CHANGELOG.md index fef642f8..a114f3d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,21 @@ date, grouped by `Added` / `Changed` / `Fixed` / `Removed`. ## [Unreleased] +## [2026-07-13] — v0.10.0 · Rich issue rendering & delivery-safe goals + +### Added + +- **Issue descriptions, comments, and Focus now render rich content consistently.** Direct images and browser-playable videos get bounded previews; supported YouTube, GitHub, Loom, and Figma links get controlled provider cards; every preview can be collapsed, hidden, restored, or opened directly with accessible controls and safe fallbacks. +- **Plan steps now have an obvious issue-like conversation path.** Each step exposes Comment and Ask @agent actions, with the same mention autocomplete used in issue threads. Mentioning an agent wakes canonical issue-backed work with the Goal, Plan, Step, dependency, and operator-comment context. + +### Changed + +- **Finished execution no longer certifies its own delivery.** When every required step passes, the Plan becomes Completed but the Goal remains Active in outcome review. A signed-in operator must record a delivered-outcome summary and at least one PR, commit, deployment, test, artifact, or other reference before accepting the Goal as Achieved. + +### Fixed + +- **Rich-rendering work stranded in an isolated runtime clone is now part of the real release history.** The six reviewed commits were recovered onto current `main`, preserving Forge's newer URL-safety policy, and the delivery lane now has an explicit reopen path for prematurely accepted Goals. + ## [2026-07-13] — v0.9.0 · Context-aware plans & issue archive ### Added diff --git a/DEVLOG.md b/DEVLOG.md index 92c250d5..752c6782 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,59 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-13 — Rich-rendering recovery + human delivery acceptance + +Recovered the six AXI-95–99 rich-rendering commits from the stale Codex bridge +clone onto current production `main`, resolving conflicts against the newer +issue context and URL-safety work. Issue descriptions, comments, and Focus now +share the rich renderer; direct media and allowlisted provider links get +bounded, accessible preview controls with inert fallbacks for unsafe schemes. + +Closed the lifecycle defect that let successful agent reviews certify their +own delivery. All-DONE steps now complete the Plan while the Goal remains +ACTIVE with `OUTCOME_REVIEW` health. A signed-in operator must explicitly +accept a non-empty outcome summary plus durable delivery evidence; premature +Goals can be reopened with an audited reason. Added migration 0099 for the +structured evidence record. Agents intentionally cannot accept/reopen Goals +over MCP. + +Made Plan collaboration visible instead of burying a generic composer under +each step. Step rows now expose **Ask @agent**, prefill the assigned agent, +focus the shared mention-aware composer, and retain ordinary comments for +context that should not dispatch work. Goal outcome review links directly to +the final step discussion. + +Verification for the v0.10.0 release: `pnpm lint` (existing repository +warnings only), `pnpm typecheck`, the full Vitest suite (**1,159 passed; 1 +skipped**), `git diff --check`, a fresh Next.js production build, and the full +Playwright suite (**37 passed**). + +## 2026-07-12 — AXI-97 rich issue rendering integration + +Integrated the shared rich body renderer into the remaining issue-adjacent +description surface. The main issue detail description and comment timeline +already render through `RichContentRenderer`; the fullscreen focus issue view +now uses the same renderer instead of raw `whitespace-pre-wrap` text, so image +URLs, video URLs, normal links, provider embeds, and Forge attachment/link +tokens preview consistently without changing stored issue description data or +the existing edit/save flows. + +Verification: `corepack pnpm lint` passed and `corepack pnpm typecheck` passed. +DB-backed tests were not run in this bridge container because it has no +Postgres/Redis/MinIO/Docker services. + +## 2026-07-11 — AXI-95 rich preview rendering contract + +Defined the shared rich preview contract for chat/message markdown surfaces. +`docs/agents/chat.md` now specifies inline image, video, file, LINK attachment, +`forge-link`, normal URL, and allowlisted provider embed behavior; max preview +sizing; collapsed/expanded/hidden states; per-preview actions; and safe +fallback/error behavior. `docs/guide/time-and-attachments.md` now links +attachment readers back to that shared contract. + +Verification: documentation diff reviewed. `corepack pnpm lint` and +`corepack pnpm typecheck` were run in the bridge container. + ## 2026-07-13 — Plan context integrity + reversible issue archive Closed the execution-context gaps that let materialized plan issues look like @@ -12281,3 +12334,70 @@ Verification: lint and typecheck passed; 1,126 unit/integration tests passed (one live-only test skipped); and a forced fresh production build plus all 37 Playwright tests passed. This change is local only; production was inspected read-only and was not deployed or mutated. +Verification: `corepack pnpm lint` clean; `corepack pnpm typecheck` pass; +`git diff --check` clean. DB-backed tests were not run because this +codex-bridge container has no Postgres/Redis service stack. + +--- + +## 2026-07-11 — AXI-96 rich issue content renderer + +Extended the shared safe markdown renderer used by issue descriptions and +comments into an explicit `RichContentRenderer` export while keeping the +existing attachment-aware renderer API compatible for other surfaces. + +The renderer now detects direct image and browser-playable video URLs in issue +content, keeps the original URL clickable/readable, and renders bounded inline +previews. Generated media/provider previews have a compact Slack-style action +menu for collapse, open, and hide/show controls, using existing tokens and +density-aware text utilities. + +Verification: `corepack pnpm lint` clean; `corepack pnpm typecheck` pass. +DB-backed tests were not run because this codex-bridge container has no +Postgres/Redis service stack. + +--- + +## 2026-07-12 — AXI-98 rich rendering behavior tests + +Added focused rich-rendering regression coverage for plain text, multiple +inline links, direct image/video previews, YouTube embed promotion, unsupported +links, malformed URLs, preview hide/collapse state transitions, and issue-detail +usage of `RichContentRenderer`. + +Extracted the preview action state into a tiny reducer used by the existing +preview controls so hide/collapse/show behavior can be tested without adding a +browser-only test dependency. + +Verification: `corepack pnpm lint` clean; `corepack pnpm typecheck` pass; +`node_modules/.bin/vitest run tests/unit/rich-rendering.test.ts` pass (7). +`corepack pnpm test` was attempted and reached the normal command, but this +codex-bridge container has no `DATABASE_URL`/Postgres service, so Prisma-backed +router/service tests failed during fixture setup. + +--- + +## 2026-07-12 — AXI-99 rich rendering accessibility/security review + +Reviewed the rich issue content renderer for XSS safety, keyboard/screen-reader +accessibility, mobile preview behavior, visual token usage, and many-preview +performance. Fixed two review blockers before approval: markdown links with +scriptable/non-approved schemes now render as plain text instead of anchors, and +preview action dropdown controls now expose explicit screen-reader labels plus +Escape handling. + +Added regression coverage for scriptable markdown link handling and preview +action labels alongside the existing rich-rendering behavior tests. + +Verification: `pnpm test tests/unit/rich-rendering.test.ts` pass (9); +`pnpm lint` clean; `pnpm typecheck` pass; `git diff --check` clean. + +Follow-up review pass: tightened provider preview polish by adding explicit +screen-reader labels to YouTube, GitHub, Loom, and Figma open controls, and +made the compact GitHub preview metadata wrap cleanly on narrow layouts. + +Added regression coverage for provider preview action labels and GitHub card +responsive constraints. + +Verification: `pnpm test tests/unit/rich-rendering.test.ts` pass (10); +`pnpm lint` clean; `pnpm typecheck` pass; `git diff --check` clean. diff --git a/docs/agents/chat.md b/docs/agents/chat.md index f06af4d1..50ca6bb5 100644 --- a/docs/agents/chat.md +++ b/docs/agents/chat.md @@ -101,6 +101,74 @@ bold/italic, inline code, fenced code blocks (with copy buttons), blockquotes, unordered lists, attachments, tool-call cards, and local SYSTEM bubbles from slash commands. +### Rich previews contract + +Chat, comments, descriptions, notes, and artifact bodies use one rendering +contract for inline media and links. The renderer is intentionally allowlist- +driven: Forge embeds only known-safe media surfaces and never executes remote +HTML or scripts from a message body. + +| Input | Default rendering | Expanded rendering | Plain-link fallback | +| --- | --- | --- | --- | +| Uploaded image attachment (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/svg+xml`) | Inline thumbnail when rendered from an image token or attachment list. | Attachment lightbox with fit-to-viewport preview. | File chip if the presigned URL, image decode, or storage lookup fails. | +| Uploaded video attachment (`video/mp4`, `video/webm`, `video/quicktime`) | Compact file chip in dense message bodies; canvas/attachment preview surfaces may show an inline player. | Attachment lightbox or preview card with native controls and no autoplay. | File chip with open/download actions if playback or presign fails. | +| Uploaded non-media file | File chip with MIME-aware icon, filename, and size. | Attachment lightbox preview when Forge has a safe renderer; otherwise open/download. | File chip remains the canonical view. | +| LINK attachment | Link chip with favicon or external-link icon, title/hostname, and external indicator. | Opens the canonical URL in a new tab. | Hostname chip if title or favicon lookup fails. | +| `forge-link:https://...` markdown token | Inline link chip matching LINK attachment styling. | Opens in a new tab. | Plain text if the scheme is not `http` or `https`. | +| Normal markdown link or bare `http(s)` URL | Standard text link. | Opens in a new tab. | Standard text link; no rich card is fetched automatically. | +| Allowlisted product embed URL | Provider embed card for known safe providers only, currently Forge artifacts plus YouTube, GitHub, Loom, and Figma. | Embedded card/player in the body, constrained to the preview size rules below. | Standard text link when provider detection fails or the provider cannot render safely. | + +Preview sizing is capped by surface: + +- Inline image thumbnails are square, approximately 80 px, object-fit cover. +- Message/body embed cards use the available body width and should cap at + 640 px wide and 360 px tall, preserving media aspect ratio. +- Attachment lightbox previews fit within the modal viewport and never force + page scroll outside the modal. +- Canvas preview cards use the node bounds; new preview cards default to a + larger card height so the preview is useful, then resize with the canvas + node. +- No preview should expand a chat bubble, comment, or note beyond the readable + column width. Long filenames, titles, and URLs truncate in the middle or end + instead of reflowing controls. + +Collapsed and hidden states: + +- Dense message bodies default to compact chips for non-image files and links. +- Each preview-capable attachment or embed has a collapsed state: chip/card + summary with filename or title, host/MIME/size metadata, and an expand action. +- Expanded previews show the media/embed in place when the surface supports + inline expansion; otherwise they open the attachment lightbox. +- Users can collapse an expanded inline preview back to the chip/card summary. +- A dismissed preview hides only that preview instance for the current surface; + it does not delete the attachment, mutate message markdown, or hide the same + attachment everywhere else. + +Per-preview actions: + +- File previews: open in new tab, download, copy short-lived presigned URL, + and delete when the current user has permission for that target. +- LINK and `forge-link` previews: open in new tab and copy canonical URL. + They do not expose download because Forge has no bytes for them. +- Embed previews: open source in new tab, copy URL, collapse, and hide preview. +- Destructive actions use the existing confirmation flow and update the + surrounding attachment list after success. + +Fallback and error behavior: + +- Presign, fetch, decode, provider-detection, favicon, and playback failures + must degrade to a chip or standard link; they must not leave broken image, + iframe, or video chrome in the message. +- Unsupported MIME types should already be rejected at upload. If older data or + bad metadata reaches the renderer, render a generic file chip. +- External HTML is never injected into the DOM. Provider embeds are implemented + with first-party components or sandboxed, allowlisted iframe URLs; unknown + URLs remain normal links. +- Remote media does not autoplay. Video controls are user-initiated, and audio + stays muted/off until the user starts playback. +- Copying a presigned file URL should label the result as short-lived because + Forge download URLs expire. + ## Status and diagnostics The right-hand Chat status rail is the operator-facing source of truth for a diff --git a/docs/concepts/orchestration.md b/docs/concepts/orchestration.md index e6469a58..9815a745 100644 --- a/docs/concepts/orchestration.md +++ b/docs/concepts/orchestration.md @@ -30,10 +30,11 @@ DAG, AgentCrew, ContextSet, ReviewGate, ActionRequest). It adds the ## How a goal runs -A **goal** is an automated loop that runs to completion — not a static -checklist you tick off by hand. You state the objective, approve the plan, -and the crew drives it through to "achieved" on its own, within the budget -and time caps you set. +A **goal** is an automated execution loop with a human delivery boundary — not +a static checklist you tick off by hand. You state the objective, approve the +plan, and the crew drives every step through review within the budget and time +caps you set. Forge completes the Plan automatically; an operator accepts the +delivered outcome before the Goal becomes achieved. 1. **Objective** — You state a high-level goal and (optionally) assign a crew to run it. @@ -45,8 +46,11 @@ and time caps you set. them in parallel. 5. **Review** — A reviewer judges each finished step. A pass advances the plan; a fail sends it back with feedback to retry. -6. **Achieved** — When every step passes, the goal completes. Budget and - time caps stop runaway loops automatically. +6. **Accept outcome** — When every step passes, the Plan completes and the Goal + remains active in `OUTCOME_REVIEW`. An operator records what shipped plus at + least one durable proof (PR, commit, deployment, test, artifact, or other + reference). Only that explicit acceptance marks the Goal achieved. Budget + and time caps stop runaway loops automatically. ### Use cases @@ -103,6 +107,12 @@ evidence. An operator's explicit `DONE` or `CANCELED` Issue transition is authoritative in the other direction: Forge updates the one unambiguous linked step, cascading dependents on Done or blocking the plan on Cancel. +Every Plan step also has an issue-like discussion thread. Use **Ask @agent** +or type `@profileKey` in the step composer to add context, ask a question, or +request follow-up without changing the step status. The mention materializes +issue-backed work when needed and wakes the named agent with the Goal, Plan, +Step, dependency, and operator-comment context attached. + ## The loop ``` @@ -148,10 +158,15 @@ step, cascading dependents on Done or blocking the plan on Cancel. │ │ └────────────────────────────────────────────────────┘ │ - │ (every step DONE) + │ (every step DONE: execution complete) ▼ + ┌────────────────┐ plan → COMPLETED; goal stays ACTIVE + │ OUTCOME_REVIEW │ operator records summary + delivery evidence + └───────┬────────┘ + │ goal.acceptOutcome (UI-only human acceptance) + ▼ ┌──────────┐ - │ ACHIEVED │ plan → COMPLETED, goal → ACHIEVED, achievedAt + │ ACHIEVED │ goal → ACHIEVED, achievedAt └──────────┘ ``` @@ -272,7 +287,8 @@ plans.recordVerdict({ stepId, verdict: "PASS", feedback: "meets contract", score # PASS → step DONE → cascade → dependents become READY → dispatch. # FAIL → retry (READY + retryCount++ + lastFeedback) or BLOCKED + gate. -# When every step is DONE, the plan COMPLETEs and the goal is ACHIEVED. +# When every step is DONE, the plan completes and the goal waits for human +# outcome acceptance with a summary and delivery evidence. ``` Crew management (admin-scoped): diff --git a/docs/guide/time-and-attachments.md b/docs/guide/time-and-attachments.md index 22b32863..8d2a9799 100644 --- a/docs/guide/time-and-attachments.md +++ b/docs/guide/time-and-attachments.md @@ -226,6 +226,11 @@ favicon (origin + `/favicon.ico`, with a Lucide `ExternalLink` fallback on image-load error) and the hostname inline. Clicking opens the URL in a new tab. +Preview behavior for images, videos, link chips, provider embeds, +collapsed states, per-preview actions, and safe fallbacks is defined in +[Chat rich previews](/agents/chat.html#rich-previews-contract). The same +contract applies anywhere Forge renders attachment or link previews. + ### `forge-link` markdown token Inside any rendered markdown body (issue descriptions, comments, diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index d9ceaccd..ec32a9fd 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -625,6 +625,13 @@ Scope: `READ_ISSUES` for reads, `WRITE_ISSUES` for the loop mutations, | `goals.create` | `{ title, description?, issueId?, crewId?, maxTotalCostUsd?, maxWallTimeMinutes? }` → `{ id }`. Emits `GOAL_CREATED`. | | `goals.abandon` | `{ id, reason? }` → terminal `ABANDONED`; cancels any active plan attempt. | +When every required step reaches `DONE`, Forge marks the active Plan +`COMPLETED` but keeps the Goal `ACTIVE` with operating health +`OUTCOME_REVIEW`. Agents can report results through comments, artifacts, and +`goals.update`, but cannot accept their own delivery. A signed-in operator must +record a non-empty outcome summary plus at least one durable evidence reference +in the Goal UI before it becomes `ACHIEVED`. + ```json // goals.create { "title": "Ship the importer", "crewId": "crew_…", "maxTotalCostUsd": 5 } @@ -735,6 +742,9 @@ These surfaces are admin/UI only and not exposed to agents: - **Dispatch analytics** — `analytics.dispatch.summary`, `analytics.dispatch.timeseries`. Aggregate views over many agents' activity, intentionally not part of an agent's self-service surface. +- **Goal outcome acceptance/reopen** — completing execution is not permission + to certify delivery. `goal.acceptOutcome` and `goal.reopen` are human UI/tRPC + actions and are intentionally absent from MCP. If you have a use case that wants one of these on MCP, open an issue — the gate is intentional but not absolute. diff --git a/package.json b/package.json index 5187902b..f377cc0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forge", - "version": "0.9.0", + "version": "0.10.0", "private": true, "description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.", "license": "MIT", diff --git a/prisma/migrations/0099_goal_outcome_evidence/migration.sql b/prisma/migrations/0099_goal_outcome_evidence/migration.sql new file mode 100644 index 00000000..dbe6dc0b --- /dev/null +++ b/prisma/migrations/0099_goal_outcome_evidence/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Goal" ADD COLUMN "outcomeEvidence" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f8b594a5..d1edbd76 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3078,6 +3078,11 @@ model Goal { successCriteria String? @db.Text /// Operator-facing closeout summary populated during or after delivery. outcomeSummary String? @db.Text + /// Operator-accepted proof that the outcome was actually delivered. Shape: + /// array of `{ kind, label, url?, ref? }` entries (PR, commit, deployment, + /// test, artifact, or other). A completed plan remains ACTIVE until a human + /// accepts a non-empty summary and at least one evidence entry. + outcomeEvidence Json? /// Optional outcome horizon; plans retain their own execution budgets. targetDate DateTime? status GoalStatus @default(OPEN) diff --git a/src/app/(app)/w/[slug]/focus/[id]/page.tsx b/src/app/(app)/w/[slug]/focus/[id]/page.tsx index 3130e7b4..cb7b2e40 100644 --- a/src/app/(app)/w/[slug]/focus/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/focus/[id]/page.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, Check, Pause, Play, RotateCcw } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { RichContentRenderer } from "@/components/markdown/attachment-renderer"; import { trpc } from "@/lib/trpc"; import { formatIssueId } from "@/lib/utils"; import { useWorkspace } from "@/hooks/use-workspace"; @@ -105,8 +106,10 @@ export default function FocusPage({ params }: { params: Promise<{ id: string }>

{issue.title}

-
- {issue.description || ( +
+ {issue.description ? ( + + ) : ( No description. )}
diff --git a/src/app/(app)/w/[slug]/goals/[goalId]/page.tsx b/src/app/(app)/w/[slug]/goals/[goalId]/page.tsx index 30ac8b8f..1be3ca1b 100644 --- a/src/app/(app)/w/[slug]/goals/[goalId]/page.tsx +++ b/src/app/(app)/w/[slug]/goals/[goalId]/page.tsx @@ -5,6 +5,7 @@ import { useParams, useRouter } from "next/navigation"; import { AlertTriangle, Ban, + CircleCheckBig, ChevronLeft, Clock3, ExternalLink, @@ -173,6 +174,23 @@ export default function GoalDetailPage() { const [confirmAbandon, setConfirmAbandon] = useState(false); const [editingGoal, setEditingGoal] = useState(false); + const [closeoutOpen, setCloseoutOpen] = useState(false); + const [reopenOpen, setReopenOpen] = useState(false); + + const acceptOutcomeM = goalRouter?.acceptOutcome?.useMutation({ + onSuccess: () => { + toast.success("Goal outcome accepted"); + invalidateGoal(); + setCloseoutOpen(false); + }, + }); + const reopenM = goalRouter?.reopen?.useMutation({ + onSuccess: () => { + toast.success("Goal reopened for delivery correction"); + invalidateGoal(); + setReopenOpen(false); + }, + }); if (!available) { return ( @@ -254,6 +272,9 @@ export default function GoalDetailPage() { ) : null; const activePlanEmpty = !!activePlan && activePlan.status === "DRAFT" && activeStepCount === 0; + const awaitingOutcomeAcceptance = + goal.status !== "ACHIEVED" && activePlan?.status === "COMPLETED"; + const discussionStep = activePlan?.steps?.at(-1) ?? null; // Offer the planner actions whenever there's nothing to run yet: no active // plan, or an active plan still sitting as an empty DRAFT (the bug the user // hit). A plan with steps hides the panel. @@ -358,6 +379,36 @@ export default function GoalDetailPage() { activeAgents={activeByAgent.size} latestActivity={latestGoalActivity ?? null} /> + {awaitingOutcomeAcceptance ? ( +
+
+
+
+ + Execution complete · delivery acceptance required +
+

+ The crew finished every plan step. The Goal remains active until you record what + shipped and attach durable proof such as a PR, commit, deployment, test, or + artifact. +

+
+
+ {discussionStep ? ( + + Comment or ask agent + + ) : null} + +
+
+
+ ) : null}
@@ -407,6 +458,32 @@ export default function GoalDetailPage() { Outcome

{goal.outcomeSummary}

+ {Array.isArray(goal.outcomeEvidence) && goal.outcomeEvidence.length > 0 ? ( +
    + {goal.outcomeEvidence.map((item, index) => ( +
  • + + {item.kind.toLowerCase().replaceAll("_", " ")} + + {item.url ? ( + + {item.label} + + ) : ( + {item.ref ?? item.label} + )} +
  • + ))} +
+ ) : null}
) : null} @@ -580,6 +657,16 @@ export default function GoalDetailPage() { Abandon goal ) : null} + {goal.status === "ACHIEVED" ? ( + + ) : null}
@@ -613,6 +700,24 @@ export default function GoalDetailPage() { }} /> ) : null} + {closeoutOpen ? ( + setCloseoutOpen(false)} + onAccept={async (payload) => { + await acceptOutcomeM?.mutateAsync({ id: goal.id, ...payload }); + }} + /> + ) : null} + {reopenOpen ? ( + setReopenOpen(false)} + onReopen={async (reason) => { + await reopenM?.mutateAsync({ id: goal.id, reason }); + }} + /> + ) : null} ); } @@ -810,6 +915,150 @@ type GoalCrewOption = { members?: unknown[]; }; +type CloseoutEvidenceKind = + | "PULL_REQUEST" + | "COMMIT" + | "DEPLOYMENT" + | "TEST" + | "ARTIFACT" + | "OTHER"; + +function inferCloseoutEvidence(value: string): { + kind: CloseoutEvidenceKind; + label: string; + url?: string; + ref?: string; +} { + const trimmed = value.trim(); + const isUrl = /^https?:\/\//i.test(trimmed); + const lower = trimmed.toLowerCase(); + const kind: CloseoutEvidenceKind = /github\.com\/[^/]+\/[^/]+\/pull\/\d+/i.test(trimmed) + ? "PULL_REQUEST" + : /^(?:commit\s+)?[0-9a-f]{7,40}$/i.test(trimmed) + ? "COMMIT" + : lower.startsWith("test:") || lower.includes(" passed") + ? "TEST" + : lower.startsWith("artifact:") + ? "ARTIFACT" + : isUrl + ? "DEPLOYMENT" + : "OTHER"; + return { + kind, + label: trimmed, + ...(isUrl ? { url: trimmed } : { ref: trimmed.replace(/^(test|artifact|commit):\s*/i, "") }), + }; +} + +function GoalCloseoutModal({ + busy, + onClose, + onAccept, +}: { + busy: boolean; + onClose: () => void; + onAccept: (payload: { + outcomeSummary: string; + evidence: Array>; + }) => void | Promise; +}) { + const [summary, setSummary] = useState(""); + const [evidenceText, setEvidenceText] = useState(""); + return ( + { + if (!open) onClose(); + }} + title="Accept delivered outcome" + description="This is the human boundary between finished execution and an achieved Goal." + primaryLabel={busy ? "Accepting…" : "Accept outcome"} + loading={busy} + className="max-w-lg" + onSubmit={async () => { + const outcomeSummary = summary.trim(); + const evidence = evidenceText + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map(inferCloseoutEvidence); + if (!outcomeSummary) return { error: "Describe what was actually delivered." }; + if (evidence.length === 0) { + return { error: "Add at least one PR, commit, deployment, test, or artifact reference." }; + } + await onAccept({ outcomeSummary, evidence }); + }} + > + +