diff --git a/AGENTS.md b/AGENTS.md index 826d40f4..b938a95e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ - A card's ordinary follow-up sends exactly the user's text after fresh task/workspace/session authorization. Give task context at session creation or through the explicit Kanban work-instructions action; do not prepend a task header to every message. - Card business status and session execution status are independent. A turn ending never automatically completes the card. - Automatic assignment, multi-agent handoffs, role orchestration, cross-host scheduling, and PR automation are outside v1. +- Exception, user-approved 2026-10-27: with a project's *Auto review* on, an agent delivery to In review starts an automatic review. Flowly may run the project's configured check commands, create a temporary review session in the card's workspace (hard-deleted when done, like the summarizer), and, when the review fails, move the card back to the work stage it was delivered from and send a marked rework message to the delivering session, up to the project's rework limit; past the limit the card goes to Blocked. On for new projects, off for existing ones until the user turns it on (user decision 2026-10-28); a pass never moves a card to Done. See `docs/AUTO-REVIEW-SPEC.md` and `docs/GUARDRAILS-SPEC.md`. - Archiving plugin records preserves history and does not implicitly archive/delete Paseo resources or directories. - Exception, explicit only: the user-confirmed **Remove** of a project deletes it and its cards from plugin data (after writing a backup to `removed/`). Only when the user also chooses *Also remove from Paseo*, and no session in its workspaces is running, the native Paseo project is removed too (`paseo project delete`, which keeps every folder, worktree and branch on disk). See `docs/PROJECT-REMOVE-SPEC.md`. - Exception, explicit only: the user-confirmed **Delete permanently** of a card (any lane) deletes that card, its events, receipts and summaries from plugin data (after writing a backup to `removed/`). Only when the user chooses *Archive* in that dialog, or has *Auto archive on delete* on and the wrap-up preflight is clean, its sessions and its own workspace are archived first (running sessions block; warnings are shown). See `docs/TASK-DELETE-SPEC.md`. @@ -57,7 +58,7 @@ ## Onboarding upkeep -- Every user-visible feature added or changed must update the new-user guidance in the same change (`docs/ONBOARDING-SPEC.md`): add or revise the matching welcome-tour copy in `client/onboarding.tsx` (concept map, loop, the Built in / Project memory / Insights pages with their switches, the More features list on the last page; `docs/ONBOARDING-SETUP-SPEC.md`) when the feature changes how Projects/Cards/Sessions map to Paseo, the basic loop, or a headline capability, and add a one-time tip (`tipIds` in `client/onboarding-logic.ts`, texts in `tipText` in `client/board.tsx`, priority in `nextTip`) when the feature has a moment worth pointing out. If neither is warranted, say so in the delivery note. Keep README highlights, tour copy and tips consistent, add every new string to all catalogs, extend `client/onboarding-logic.test.ts` for new tip rules, and update the spec's tip table. +- Every user-visible feature added or changed must update the new-user guidance in the same change (`docs/ONBOARDING-SPEC.md`): add or revise the matching welcome-tour copy in `client/onboarding.tsx` (concept map, loop, the Built in / Guardrails / Insights pages with their switches, the More features list on the last page; `docs/ONBOARDING-SETUP-SPEC.md`) when the feature changes how Projects/Cards/Sessions map to Paseo, the basic loop, or a headline capability, and add a one-time tip (`tipIds` in `client/onboarding-logic.ts`, texts in `tipText` in `client/board.tsx`, priority in `nextTip`) when the feature has a moment worth pointing out. If neither is warranted, say so in the delivery note. Keep README highlights, tour copy and tips consistent, add every new string to all catalogs, extend `client/onboarding-logic.test.ts` for new tip rules, and update the spec's tip table. - A new tip id is not in anyone's stored `tipsSeen`, so it is shown once to every user, including existing ones: that is how new features get announced. Do not add it to a stored list. Existing users were only marked seen for the tip ids that existed at that time. - When a minor or major release (x.Y.0 or X.0.0) changes something existing users will not run into themselves (a new setting, a switch whose default differs for them, a moved entry point), add that release on top of `client/whats-new.ts` (its user-visible new features, one sentence each, at most 7, optional Open settings / Take the tour button; older releases stay, back to 1.5.0). Existing users see the newest release once after updating and can page back to older ones; new users skip it because the tour covers it (`docs/WHATS-NEW-SPEC.md`). Patch releases (x.y.Z) never add a What's new entry (user decision 2026-10-27). diff --git a/README.md b/README.md index 4d4aa0d2..641b1bc1 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,9 @@ After every completed turn a cheap model (Haiku 5.5 by default, configurable) up ![Card summary: state, target, recap and next step](docs/promo/screenshots/03-card-summary.png) -Agents also keep a **Workpad** on the card: acceptance criteria, the checks they run (passed, failed or not run, each on a named commit, with the exact command) and what in the card confused them. Their plan comes from their own todo list. The card shows **Checks 3/5**; a check that ran on an older commit than the workspace's HEAD reads *out of date*. You can tick criteria and set a check's state yourself. Before they start, agents set the card's type and tags and write the workpad; until they do, every Flowly tool result reminds them. An agent delivering to In review with open items, or without a type, tags or workpad, has to acknowledge that with a note, and with open items the card then says **Delivered with N open items**. Turn it off per project with *Card workpad* in the project settings. +Agents also keep a **Workpad** on the card: the acceptance list they write before they start (ticked as they meet each item; you can tick them too), what the card was sent back for, the latest review and their plan from their own todo list. The card shows **Acceptance 3/5**. Before they start, agents set the card's type and tags and write the acceptance list; until they do, every Flowly tool result reminds them, and delivering without them needs a note. Turn the acceptance list off per project with *Card workpad* in the project settings. + +**Auto review** (on for new projects; turn it on for an existing one in its settings or from What's new): when an agent delivers a card to In review, Flowly checks it before it reaches you. It runs the project's check commands (found in the repository, for example `npm test`; a newly found one only reports during its first three reviews), then a separate reviewer judges whether the change does what you asked on the card and in its sessions and whether it meets the project's guidelines. A failure sends the card back to the stage it came from with what fell short, in your own words; after the rework limit the card goes to Blocked. The card shows **Checked** or **Sent back**, and a strip under it follows the review while it runs. Checked does not mean right: Done is still your call. Each review uses tokens. ### 4. Follow-up ideas @@ -67,7 +69,9 @@ Work an agent discovers along the way (a bug it tripped over, a limitation, the ![Ideas inbox with source cards](docs/promo/screenshots/04-ideas-inbox.png) ![Good idea: start a new task, continue in the session, or use a new session](docs/promo/screenshots/04b-good-idea-two-paths.png) -What should outlive a card goes to **project memory**: decisions you made (including the options you turned down), pitfalls and dead ends, how you want work done, and the people and systems involved. Agents suggest entries with `flowly_remember`, and a cheap model reviews each card you move to Done and suggests at most one more, only when it holds beyond that card; nothing counts until you confirm it on the project's **Memory** page (`M`). Confirmed entries go into the first instructions of the project's new sessions (all of them while they fit, otherwise the pinned and most relevant ones; agents look up the rest with `flowly_recall`). You can edit, pin, archive or delete any entry, copy one as an `AGENTS.md` rule, and turn memory off per project. It is stored on your machine, never in the repository. +**Guardrails** (`G`) are what the project checks, grown from the cards you send back. When you send a card back, Flowly notes what was wrong on that card; when a second card goes back for the same thing, it becomes a project guideline, checked on every later delivery it applies to. Each guideline shows your words, how often it caught, missed or misfired, and the kinds of change it applies to; one that keeps misfiring or never catches anything retires by itself, and comes back if the problem does. Next to them are the project's check commands. You can edit, pause, archive or delete any of it, and turn guardrails off per project. + +**Memory** (`M`) holds the project's decisions, pitfalls, conventions and contacts that agents save with `flowly_remember` or that the review of a finished card finds, and the ones you add. Memory takes effect at once in new sessions' first instructions (agents look up the rest with `flowly_recall`). Filter by kind, sort by newest or most used, switch between a list and a grid, pin, edit, archive, or copy one as an `AGENTS.md` rule. Guardrails and memory are stored on your machine, never in the repository. **Search** (`S`, or the search button) looks through past cards: titles, summaries, progress notes, workpads, ideas and deliverables, so pasting a PR link or a Jira key finds the card behind it. It covers the current project or all of them, and runs on your machine. Agents search the same history with `flowly_search` when they start on an area. @@ -93,7 +97,7 @@ Insights shows a year of the project's activity, GitHub style: one square per da -**Skills** (on by default, turn it off in the welcome tour or Settings → AI features): every week a model you choose reads the project's cards (summaries, acceptance criteria, your own corrections kept by project memory), confirmed memory and the redacted shell commands agents ran, and drafts complete skills, a procedure agents did differently each time (shipping through a pull request and waiting for review) or what several cards of one kind taught (plugin performance). **Add to project** writes `.agents/skills//SKILL.md` (read by Codex) and links it from `.claude/skills/` (read by Claude Code); nothing is committed for you. Later runs propose updates to the skills Flowly added, shown as a diff. The same run still suggests `paseo.json` scripts, worktree setup, repo tools and AGENTS.md rules; each becomes a card with one click. Each run replaces the drafts nobody acted on; quiet weeks are skipped without calling the model, and the first scheduled run waits one full period. Agents see the project's `paseo.json` scripts in their task context. +**Skills** (on by default, turn it off in the welcome tour or Settings → AI features): every week a model you choose reads the project's cards (summaries, acceptance criteria, your own corrections), the project's notes and guidelines and the redacted shell commands agents ran, and drafts complete skills, a procedure agents did differently each time (shipping through a pull request and waiting for review) or what several cards of one kind taught (plugin performance). **Add to project** writes `.agents/skills//SKILL.md` (read by Codex) and links it from `.claude/skills/` (read by Claude Code); nothing is committed for you. Later runs propose updates to the skills Flowly added, shown as a diff. The same run still suggests `paseo.json` scripts, worktree setup, repo tools and AGENTS.md rules; each becomes a card with one click. Each run replaces the drafts nobody acted on; quiet weeks are skipped without calling the model, and the first scheduled run waits one full period. Agents see the project's `paseo.json` scripts in their task context. ## Languages @@ -105,7 +109,7 @@ The interface follows your device language, or pick one in **Flowly settings → ## Quick start -The first time you open Flowly, a short welcome tour explains projects, cards and sessions and sets the basics: interface and AI output language, the progress model, whether progress, next steps and ideas also apply to sessions outside Flowly (on by default; each of their turns then runs the summary model once), and whether Project memory and Insights are on. Skip keeps the defaults. Change any of it later from the gear at the bottom left. +The first time you open Flowly, a short welcome tour explains projects, cards and sessions and sets the basics: interface and AI output language, the progress model, whether progress, next steps and ideas also apply to sessions outside Flowly (on by default; each of their turns then runs the summary model once), and whether Guardrails and Insights are on. Skip keeps the defaults. Change any of it later from the gear at the bottom left. 1. **New project**: pick a directory (or several repositories) to register as a native Paseo project. 2. **New task**: describe the work; optionally start an agent together with the card. diff --git a/README.zh-CN.md b/README.zh-CN.md index cbe9fe57..b3e09cab 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -56,7 +56,9 @@ agent 需要你拍板时,会提出一个明确的问题并停下。卡片亮 ![卡片 summary:状态、目标、已完成、下一步](docs/promo/screenshots/03-card-summary.png) -agent 还会在卡片上维护一份 **Workpad**:验收标准、它跑的验证项(通过、失败或未运行,各自标明在哪个 commit 上跑、用的什么命令),以及卡片里让它困惑的地方。计划直接取自 agent 自己的待办列表。卡片上显示 **验证 3/5**;在比当前 HEAD 更旧的 commit 上跑的验证会标为*已过期*。你也可以自己勾选验收标准、改验证状态。agent 开工前会先设好卡片的类型和标签并写好 Workpad;没设好之前,每次调用 Flowly 工具的结果都会提醒它。带着未完成项、或缺类型/标签/Workpad 交付到 In review 时,agent 必须明确确认并附上说明;有未完成项时卡片随后标出 **交付时 N 项未完成**。可在项目设置的 *Card workpad* 里按项目关闭。 +agent 还会在卡片上维护一份 **Workpad**:开工前写下的验收清单(达成一项勾一项,你也可以勾)、这张卡因为什么被退回、最近一次验收,以及取自 agent 自己待办列表的计划。卡片上显示 **验收标准 3/5**。agent 开工前会先设好卡片的类型和标签并写好验收清单;没设好之前,每次调用 Flowly 工具的结果都会提醒它,缺这些交付要附说明。可在项目设置的 *Card workpad* 里按项目关闭验收清单。 + +**自动验收**(新项目默认开启;老项目在项目设置或 What's new 里打开):agent 把卡片交付到 In review 时,Flowly 先查一遍再交给你。先跑项目的检查命令(从仓库识别,例如 `npm test`;新识别的命令前三次评审只报告结果),再由一个独立的评审判断改动是否做到了你在卡片和 session 里要求的事,以及是否符合项目准则。不通过就退回原来的工作阶段,并用你自己的原话说明哪里不够;超过返工上限进 Blocked。卡片显示 **已查过** 或 **已退回**,评审进行时卡片底部有一条指示条。查过不代表做对了:Done 还是你定。每次评审都消耗 token。 ### 4. 任务延伸 ideas @@ -65,7 +67,9 @@ agent 工作时发现的后续事项(顺带发现的 bug、现有方案的局 ![Ideas 收件箱,标明来源卡片](docs/promo/screenshots/04-ideas-inbox.png) ![Good idea:新建任务、在原 session 继续,或开新 session](docs/promo/screenshots/04b-good-idea-two-paths.png) -需要比卡片活得更久的东西进入**项目记忆**:你做的决策(包括否决的方案)、踩过的坑和走不通的路、你希望怎么做事,以及相关的人和系统。agent 用 `flowly_remember` 提出候选;卡片移入 Done 时,一个便宜的模型还会复盘它,最多再提 1 条,而且只提跨卡通用的。所有条目都要你在项目的 **Memory** 页(快捷键 `M`)确认后才生效。确认过的条目会进入这个项目新 session 的初始指令(放得下就全部给,放不下就给置顶的和最相关的,其余由 agent 用 `flowly_recall` 查)。每条都可以修改、置顶、归档或删除,也可以复制成 `AGENTS.md` 规则;可以按项目关闭。记忆只存在本机,不进仓库。 +**护栏**(快捷键 `G`)是这个项目查什么,从你退回的卡片里长出来。你退回一张卡,Flowly 在这张卡上记下哪里不对;第二张卡因为同一件事被退回,它就成为项目准则,之后每次适用的交付都按它检查。每条准则显示你的原话、抓到、漏掉、误判各几次,以及适用于哪类改动;老是误判或从没抓到过的会自动退休,问题再出现时又会回来。同一页还有项目的检查命令。每条都可以修改、暂停、归档或删除;可以按项目关闭护栏。 + +**记忆**(快捷键 `M`)是项目的决策、坑、约定和联系人:agent 用 `flowly_remember` 记下的、完成卡片的复盘找到的,以及你自己加的。记忆立即进入新 session 的初始指令(其余由 agent 用 `flowly_recall` 查)。可以按类型筛选、按最新或最常用排序、在列表和网格之间切换,可以置顶、修改、归档,或复制成 `AGENTS.md` 规则。护栏和记忆只存在本机,不进仓库。 **搜索**(快捷键 `S` 或搜索按钮)查找过去的卡片:标题、摘要、进度说明、Workpad、想法和交付物,粘贴 PR 链接或 Jira 编号就能找到对应的卡。可以搜当前项目或全部项目,完全在本机进行。agent 开始做某个方向时也能用 `flowly_search` 查同样的历史。 @@ -91,7 +95,7 @@ Insights 用 GitHub 式的热力图显示项目一年的活跃度:每天一格 -**Skills**(默认开启,可在欢迎导览或 设置 → AI 功能 里关闭):每周由你选的模型阅读项目的卡片(总结、验收条目、项目记忆记下的你的纠正)、确认过的记忆和 agent 执行过的 shell 命令(已脱敏),写出完整的 skill 草稿:agent 每次做法都不一样的流程(例如走 PR 合入、等评审),或同一类卡积累下来的经验(例如插件性能优化)。点 **加入项目** 会写入 `.agents/skills//SKILL.md`(Codex 读取),并在 `.claude/skills/` 建软链接(Claude Code 读取),不会替你提交。之后的运行会对 Flowly 加入的 skill 提出更新,以 diff 显示。同一次运行仍会建议 `paseo.json` 脚本、worktree setup、仓库工具和 AGENTS.md 规则,每条一键生成卡片。每次运行替换没人处理的草稿;没有新活动的周期直接跳过,不调用模型;第一次定时运行要等满一个周期。agent 的任务上下文会列出项目 `paseo.json` 中的脚本。 +**Skills**(默认开启,可在欢迎导览或 设置 → AI 功能 里关闭):每周由你选的模型阅读项目的卡片(总结、验收条目、你的纠正)、项目的备忘和准则,以及 agent 执行过的 shell 命令(已脱敏),写出完整的 skill 草稿:agent 每次做法都不一样的流程(例如走 PR 合入、等评审),或同一类卡积累下来的经验(例如插件性能优化)。点 **加入项目** 会写入 `.agents/skills//SKILL.md`(Codex 读取),并在 `.claude/skills/` 建软链接(Claude Code 读取),不会替你提交。之后的运行会对 Flowly 加入的 skill 提出更新,以 diff 显示。同一次运行仍会建议 `paseo.json` 脚本、worktree setup、仓库工具和 AGENTS.md 规则,每条一键生成卡片。每次运行替换没人处理的草稿;没有新活动的周期直接跳过,不调用模型;第一次定时运行要等满一个周期。agent 的任务上下文会列出项目 `paseo.json` 中的脚本。 ## 界面语言 @@ -103,7 +107,7 @@ Insights 用 GitHub 式的热力图显示项目一年的活跃度:每天一格 ## 快速上手 -第一次打开 Flowly 时,一个简短的欢迎导览会讲清 project、卡片和 session,并完成基础设置:界面语言和 AI 输出语言、进展模型、进展 / 下一步 / 想法是否也用于 Flowly 之外的 session(默认开;这些 session 每完成一轮会运行一次摘要模型),以及项目记忆和 Insights 是否开启。Skip 即保留默认值。之后可以在左下角的齿轮里随时修改。 +第一次打开 Flowly 时,一个简短的欢迎导览会讲清 project、卡片和 session,并完成基础设置:界面语言和 AI 输出语言、进展模型、进展 / 下一步 / 想法是否也用于 Flowly 之外的 session(默认开;这些 session 每完成一轮会运行一次摘要模型),以及护栏和 Insights 是否开启。Skip 即保留默认值。之后可以在左下角的齿轮里随时修改。 1. **New project**:选择一个目录(或多个仓库),把它注册为原生 Paseo project。 2. **New task**:描述要做的事,可以同时启动第一个 agent。 diff --git a/client/board.tsx b/client/board.tsx index 1b8868e3..7c55788c 100644 --- a/client/board.tsx +++ b/client/board.tsx @@ -64,7 +64,7 @@ import { waitingOn } from "../shared/links"; import { LinkPicker } from "./task-links"; import { TaskDrawer } from "./drawer"; import { DeleteTaskDialog } from "./task-delete"; -import { useOverviewQuery, useLiveAgents, useMemoryCountsQuery, useResourcesQuery, useWorkflowInsightsQuery } from "./queries"; +import { useOverviewQuery, useLiveAgents, useGuardrailCountsQuery, useResourcesQuery, useWorkflowInsightsQuery } from "./queries"; import { projectOpenIdeaCount } from "./overview-query"; import { useStableCallback, useStableTheme } from "./stable"; import { isLegacyScope } from "../shared/scope"; @@ -102,7 +102,8 @@ import { IdeasPage } from "./ideas-page"; import { SearchPage } from "./search-page"; import { focusOfHit, type DrawerFocus } from "./drawer-focus"; import { searchSources as allSearchSources, type SearchCard, type SearchHit, type SearchMemoryHit } from "../shared/search"; -import { MemoryPage } from "./memory"; +import { GuardrailsPage } from "./guardrails"; +import { MemoryPage, type MemoryPrefs } from "./memory"; import { lastViewedProject, pickProject, rememberViewedProject } from "./project-memory"; import { ProjectTabs } from "./project-tabs"; import { RemoveProjectDialog } from "./project-remove"; @@ -137,6 +138,8 @@ import { PROJECT_UNAVAILABLE, gone } from "../shared/card-detail"; import { workpadEnabled } from "../shared/workpad"; import { cardDisplay } from "../shared/card-display"; import { parentOf } from "../shared/subagents"; +import { reviewLabel } from "./task-review"; +import { visibleReview } from "../shared/auto-review"; /** One-time tip texts (ONBOARDING-SPEC §5). */ const tipText: Record string> = { @@ -158,14 +161,15 @@ const tipText: Record string> = { "ai-language": () => t("AI-written text follows Settings → AI features → AI output language. It starts as your interface language."), "first-skill-draft": () => t("Insights drafted a skill from your cards. Open Insights to add it to the project."), search: () => t("Find past cards, notes, workpads and deliverables with the search button or S."), - "first-memory-candidate": () => t("An agent suggested something to remember. Confirm it in Memory and new sessions on this project will know it; you can edit or delete it any time."), "first-workflow-insight": () => t("Workflow insights found repeated work worth keeping. Open Insights to turn a suggestion into a card."), "card-display": () => t("Cards too busy? Choose what they show in Settings → Cards, or from the Sort menu → Card display."), "attention-dismiss": () => t("Not ready to decide? Dismiss a card from Attention. It comes back when something new happens."), "sidebar-order": () => t("Arrange an open card your way: reorder its sections and choose which start open in Settings → Cards → Card sidebar."), "session-history": () => t("A session's output now starts at the beginning of its current turn. Press Load earlier at the top to read further back."), "settings-entry": () => t("Turn features on or off and change settings here."), - "first-workpad": () => t("This card has a workpad: the agent's acceptance criteria and checks. The card shows how many checks passed; open it to see which ran on which commit, and tick items yourself."), + "auto-review": () => t("Auto review checked this card after the agent delivered; open it to see each check's result."), + "first-guardrail": () => t("Flowly learned a check from a card you sent back. Open Guardrails to see what it checks now."), + "first-workpad": () => t("This card has a workpad: the agent's acceptance list. The card shows how many items are met; open it to tick items yourself and to see the latest review."), }; /** RN-Web forwards React keyboard events on View; React Native's types omit them. */ @@ -323,11 +327,13 @@ function BoardContent( [tourClosed, setTourClosed] = useState(false), // What's new after an update (WHATS-NEW-SPEC): opened by itself once per release, or from Settings. [whatsNewOpen, setWhatsNewOpen] = useState(false), + // Edit project opened to suggest auto review checks from past cards (AUTO-REVIEW-SPEC §6.2). + [reviewOn, setReviewOn] = useState(false), // The user just moved a card into Done/Cancelled (drives the wrap-up tip). [movedToFinished, setMovedToFinished] = useState(false), [escapeRequest, setEscapeRequest] = useState<{ taskId: string; key: string } | null>(null), // Project-level page over the board area: Insights or Ideas (IDEAS-INBOX-SPEC §3.3). - [page, setPage] = useState<"insights" | "ideas" | "memory" | "search" | null>(null), + [page, setPage] = useState<"insights" | "ideas" | "guardrails" | "memory" | "search" | null>(null), // Search page (SEARCH-SPEC §5): the text it opens with and a key that remounts it for a new seed. [searchSeed, setSearchSeed] = useState({ text: "", key: 0 }), // A search hit for the drawer to land on (DRAWER-SPEC "Focus from search"); any other way of opening a card clears it. @@ -1035,14 +1041,15 @@ function BoardContent( if (target) { setDetailTarget(target); setProject(target.projectId); } setTask(id); }); - const togglePage = (next: "insights" | "ideas" | "memory" | "search") => { + const togglePage = (next: "insights" | "ideas" | "guardrails" | "memory" | "search") => { ignoredLink.current = linkKey; // Opening Insights is acting on the skill-draft tip (INSIGHTS-REDESIGN-SPEC §5). if (next === "insights" && onboarding.values && !onboarding.values.tipsSeen.includes("first-skill-draft")) void onboarding.markTipSeen("first-skill-draft"); + // Opening Guardrails is acting on the learned-guideline tip (GUARDRAILS-SPEC §10). + if (next === "guardrails" && onboarding.values && !onboarding.values.tipsSeen.includes("first-guardrail")) void onboarding.markTipSeen("first-guardrail"); setPage((current) => (current === next ? null : next)); }; const closePage = useStableCallback(() => setPage(null)); - const openMemoryPage = useStableCallback(() => { ignoredLink.current = linkKey; setPage("memory"); }); // SEARCH-SPEC §5.3: a result opens its card, switching project first; the Search page stays open. const openSearchCard = useStableCallback((card: SearchCard, hit?: SearchHit) => { ignoredLink.current = linkKey; @@ -1092,10 +1099,16 @@ function BoardContent( const projectIdeasData = useProjectIdeasQuery(props.host.id, project?.id || "", page === "ideas" && (!compact || !taskId)); const projectIdeaList = useMemo(() => projectIdeasData.data?.ideas || [], [projectIdeasData.data]); const projectOpenIdeas = useMemo(() => projectOpenIdeaCount(projected, project?.id), [projected?.cards, projected?.tasks, project?.id]); - // Project memory candidates waiting for review (PROJECT-MEMORY-SPEC §8.1); the chip hides when the project turned memory off. - const memoryCounts = useMemoryCountsQuery(boardActive && !!project && features.memory); - const memoryOn = features.memory && !!project && project.memory !== false; - const memoryCandidates = memoryOn ? (memoryCounts.data?.[project!.id] ?? 0) : null; + // Observing commands and guidelines learned this week (GUARDRAILS-SPEC §7.4); the chip hides when the project turned guardrails off. + const guardrailCounts = useGuardrailCountsQuery(boardActive && !!project && features.memory); + const guardrailsOn = features.memory && !!project && project.memory !== false; + const projectGuardrails = guardrailsOn ? guardrailCounts.data?.[project!.id] : undefined; + const guardrailCount = guardrailsOn ? (projectGuardrails ? projectGuardrails.observing + projectGuardrails.added : 0) : null; + const memoryCount = guardrailsOn ? (projectGuardrails?.notes ?? 0) : null; + // The Memory page's filter, sort and layout (board-view settings, GUARDRAILS-SPEC §7.4). + const memoryKind = view.values?.memoryKind ?? "all", memorySort = view.values?.memorySort ?? "newest", memoryLayout = view.values?.memoryLayout ?? "list"; + const memoryPrefs = useMemo(() => ({ memoryKind, memorySort, memoryLayout }), [memoryKind, memorySort, memoryLayout]); + const saveMemoryPrefs = useStableCallback((patch: Partial) => void view.update(patch)); const projectTasks = useMemo(() => (board?.tasks || []).filter((t) => t.projectId === project?.id), [board?.tasks, project?.id]); // A session of this project has a live Paseo child agent (SUBAGENTS-SPEC §7). const projectHasSubagent = useMemo(() => { @@ -1175,10 +1188,16 @@ function BoardContent( setWhatsNewOpen(false); void onboarding.markWhatsNewSeen(); }; + // What's new "Turn on for this project": the current project's Edit project, Auto review first and switched on. + const turnOnReview = () => { + if (!project) return; + setReviewOn(true); + setProjectForm("edit"); + }; const whatsNewAction = (action: NonNullable) => { closeWhatsNew(); // Settings and the tour are modals too: open them once this one has gone. - afterWhatsNew(() => (action.to === "settings" ? openSettingsAt(action.section) : setWelcomeOpen(true))); + afterWhatsNew(() => (action.to === "settings" ? openSettingsAt(action.section) : action.to === "auto-review" ? turnOnReview() : setWelcomeOpen(true))); }; const seeSettingsHint = () => { if (settingsHint) void onboarding.markTipSeen("settings-entry"); @@ -1210,8 +1229,9 @@ function BoardContent( hasSummary: !task && !!summaries.data?.latest.some((e) => projectTasks.some((x) => x.id === e.taskId)), workflowSuggestion: features.insights && workflowTipDue && !!workflowView.data?.findings.some((f) => f.status === "open" && !(f.target === "skill" && f.skill)), skillDraft: features.insights && workflowTipDue && !!workflowView.data?.findings.some((f) => f.status === "open" && f.target === "skill" && !!f.skill), - memoryCandidate: !task && !!memoryCandidates, searchable: !task && projectTasks.filter((x) => !x.archivedAt).length >= 10, + reviewShown: !task && projectTasks.some((x) => !!visibleReview(x)), + learnedGuideline: !task && !!projectGuardrails?.added, }, blocked: !onboarding.values || welcomeOpen || welcome === "show" || dialogOpen || settingsHint || whatsNewOpen || whatsNewDue === "show", }); @@ -1333,7 +1353,12 @@ function BoardContent( ...(ideasOn ? [{ id: "ideas", label: t("Ideas"), icon: "Lightbulb", onPress: () => openIn(() => { setIdeaCard(""); setPage("ideas"); }) }] : []), - ...(features.memory && p.memory !== false ? [{ id: "memory", label: t("Memory"), icon: "BookMarked", onPress: () => openIn(() => setPage("memory")) }] : []), + ...(features.memory && p.memory !== false + ? [ + { id: "guardrails", label: t("Guardrails"), icon: "ShieldCheck", onPress: () => openIn(() => setPage("guardrails")) }, + { id: "memory", label: t("Memory"), icon: "BookMarked", onPress: () => openIn(() => setPage("memory")) }, + ] + : []), { kind: "separator", id: "order" }, { id: "left", label: t("Move left"), icon: "ChevronLeft", disabled: index === 0 || pending, onPress: () => reorderProject(p.id, { direction: "up" }) }, { id: "right", label: t("Move right"), icon: "ChevronRight", disabled: index === projects.length - 1 || pending, onPress: () => reorderProject(p.id, { direction: "down" }) }, @@ -1381,6 +1406,10 @@ function BoardContent( setIdeaCard(""); togglePage("ideas"); return true; + case "guardrails": + if (!project || !features.memory) return false; + togglePage("guardrails"); + return true; case "memory": if (!project || !features.memory) return false; togglePage("memory"); @@ -1450,6 +1479,7 @@ function BoardContent( }, [props.params?.intent, props.params?.at, boardPending]); const attentionDetail = (item: AttentionItem) => `${item.project.title} · ${laneName(item.project, item.task.status)} · ${[ + item.review && item.task.review && reviewLabel(item.task.review), item.questions && tn(item.questions, "{n} question", "{n} questions"), item.permissions && tn(item.permissions, "{n} approval", "{n} approvals"), item.errors && tn(item.errors, "{n} error", "{n} errors"), @@ -1476,8 +1506,10 @@ function BoardContent( color={ item.dismissed ? colors.foregroundMuted - : item.permissions || item.questions + : item.permissions || item.questions || item.review === "unavailable" ? colors.statusWarning + : item.review === "failed" + ? colors.statusDanger : item.errors ? colors.statusDanger : colors.statusSuccess @@ -1614,7 +1646,7 @@ function BoardContent( } startIdea={startRequest?.taskId === drawer.item.task.id ? startRequest.idea : undefined} onAllIdeas={showCardIdeas} - onMemoryPage={features.memory ? openMemoryPage : undefined} + guardrails={features.memory && drawer.item.project.memory !== false} sessionEntry={ sessionEntry?.taskId === drawer.item.task.id && drawer.active ? sessionEntry : undefined } @@ -1771,7 +1803,10 @@ function BoardContent( togglePage("ideas"); }} ideasShortcut={shortcutsOn ? "B" : ""} - memoryCandidates={memoryCandidates} + guardrailCount={guardrailCount} + onGuardrails={() => togglePage("guardrails")} + guardrailsShortcut={shortcutsOn ? "G" : ""} + memoryCount={memoryCount} onMemory={() => togglePage("memory")} memoryShortcut={shortcutsOn ? "M" : ""} onLane={(status) => { @@ -1882,6 +1917,7 @@ function BoardContent( onBack={closePage} onCreateFromFinding={createFromFinding} onOpenSettings={openAiSettings} + onOpenGuardrails={guardrailsOn ? () => setPage("guardrails") : undefined} /> ) : page === "ideas" && ideasOn && board ? ( + ) : page === "guardrails" && board ? ( + ) : page === "memory" && board ? ( - + ) : compact ? ( <> @@ -2413,17 +2451,22 @@ function BoardContent( )} {projectForm && ( [s, tasks.filter((t) => t.status === s && !t.archivedAt).length]), )} showArchived={archived} onShowArchived={setArchived} - onClose={() => setProjectForm(null)} + onClose={() => { + setProjectForm(null); + setReviewOn(false); + }} onSaved={(id, restored) => { chooseProject(id); setProjectForm(null); + setReviewOn(false); refresh(); if (restored) toast.show(tn(restored.restored, "Restored {title} with {n} card.", "Restored {title} with {n} cards.", { title: restored.title }), { variant: "success" }); }} diff --git a/client/card-settings.tsx b/client/card-settings.tsx index 3a4b232a..e36d6cc4 100644 --- a/client/card-settings.tsx +++ b/client/card-settings.tsx @@ -252,7 +252,7 @@ function CardPreview({ display }: { display: ReturnType }) { progress={sample.progress} ideas={1} externalKey="SHOP-142" - workpad={{ kind: "checks", done: 2, total: 3, count: 0 }} + workpad={{ kind: "acceptance", done: 2, total: 3 }} display={display} onOpen={noop} onActions={noop} diff --git a/client/card.tsx b/client/card.tsx index 90978fa1..84592fa7 100644 --- a/client/card.tsx +++ b/client/card.tsx @@ -1,12 +1,12 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Animated, PanResponder, Pressable, View } from "react-native"; import { Icon } from "@getpaseo/plugin/client/react-native"; -import { taskTypeLabels, type Task } from "../shared/model"; +import { taskTypeLabels, type Review, type Task } from "../shared/model"; import { AiMark, Badge, Dot, IconButton, Label, font, radius, relativeTime, space, tint, touch, useTheme, noFocusRing } from "./ui"; import { PriorityIcon, TypeIcon, priorityAccent, priorityLabels } from "./status"; import type { Point } from "./geometry"; import type { SessionAttentionSummary } from "../shared/session-attention"; -import { PulseDot, motionDuration, useMotionValue } from "./motion"; +import { PulseDot, motionDuration, useMotionValue, useReducedMotion } from "./motion"; import { RunningDots } from "./running-motion"; import { useCardOutput, type CardOutput, type OutputSession } from "./card-output"; import { menuHandlers, useMenuOpener, type MenuEntry } from "./context-menu"; @@ -15,6 +15,10 @@ import { t, tn } from "../shared/i18n"; import type { WorkpadSignal } from "../shared/workpad"; import { activityLines, defaultCardDisplay, type CardDisplay } from "../shared/card-display"; import { BrandIcon } from "./brand-icons"; +import { reviewing, visibleReview } from "../shared/auto-review"; +import { Collapse } from "./disclosure"; +import { reviewIcon, reviewLabel } from "./task-review"; +import { reviewProgress } from "../shared/auto-review"; import type { ConnectorFamily } from "../shared/connectors"; export type CardDrag = { @@ -267,6 +271,7 @@ export function TaskCard({ waiting ? tn(waiting, "Waiting on {n} card: {waitingTitles}", "Waiting on {n} cards: {waitingTitles}", { waitingTitles }) : "", progress ? t("Progress: {state}", { state: progress.state }) : "", workpad ? workpadText(workpad) : "", + task.review && visibleReview(task) ? reviewLabel(task.review) : "", attentionDismissed ? t("Dismissed from Attention") : "", [t("{priority} priority", { priority: priorityLabels[task.priority] }), task.type ? taskTypeLabels[task.type] : "", task.archivedAt ? t("archived") : "", legacy ? t("legacy scope") : ""].filter(Boolean).join(", "), summaryState === "ready" && summary @@ -321,6 +326,7 @@ export function TaskCard({ display={display} /> + {accent && ( // Urgent/High: a stripe down the left edge, readable from across the board. { + const ended = was.current && !running; + was.current = running; + if (!ended || reduced) { + setHeld(false); + return; + } + setHeld(true); + const timer = setTimeout(() => setHeld(false), 600); + return () => clearTimeout(timer); + }, [running, reduced]); + const open = running || held; + const progress = review ? reviewProgress(review) : null; + const passed = progress && progress.total ? t("{passed}/{total} passed", { passed: progress.passed, total: progress.total }) : ""; + const failed = progress?.failed ? tn(progress.failed, "{n} failed", "{n} failed") : ""; + const now = + progress?.phase === "running" && progress.current + ? t("running {name}", { name: progress.current }) + : progress?.phase === "judging" + ? tn(progress.judging ?? 0, "judging {n} check", "judging {n} checks") + : progress?.phase === "starting" + ? t("starting…") + : ""; + const label = [ + t("Auto review running"), + progress?.total ? t("{passed} of {total} passed", { passed: progress.passed, total: progress.total }) : "", + failed, + now, + ].filter(Boolean).join(", "); + return ( + + + + + + {!!passed && } + {!!failed && } + {!!now && ( + + + + )} + + + ); +} + +/** "· text" after the strip's lead word; muted unless coloured. */ +function StripPart({ text, color }: { text: string; color?: string }) { + return ( + + ); +} + function CardSignals({ task, legacy, @@ -504,16 +595,19 @@ function CardSignals({ } } else if (sessions && display.sessions) items.push(); - if (attentionDismissed) items.push(); - if (workpad && display.workpad) + const review = visibleReview(task); + // A running review has its own strip under the card. + if (review && review.state !== "running") items.push( , ); + if (attentionDismissed) items.push(); + if (workpad && display.workpad && workpadText(workpad)) items.push(); if (ideas && display.ideas) items.push(); if (task.archivedAt) items.push({t("Archived")}); if (legacy) items.push({t("Legacy")}); @@ -569,18 +663,7 @@ function Meta({ label, icon, text, color }: { label: string; icon: ReactNode; te ); } -/** The card's workpad signal (WORKPAD-SPEC §6.1). */ +/** The card's workpad signal: only `Acceptance n/m` (GUARDRAILS-SPEC §7.1). */ function workpadText(signal: WorkpadSignal) { - switch (signal.kind) { - case "delivered": - return tn(signal.count, "Delivered with {n} open item", "Delivered with {n} open items"); - case "failed": - return t("Checks {done}/{total} · {failed} failed", { done: signal.done, total: signal.total, failed: signal.count }); - case "stale": - return t("Checks {done}/{total} · out of date", { done: signal.done, total: signal.total }); - case "checks": - return t("Checks {done}/{total}", { done: signal.done, total: signal.total }); - case "acceptance": - return t("Acceptance {done}/{total}", { done: signal.done, total: signal.total }); - } + return signal.kind === "acceptance" ? t("Acceptance {done}/{total}", { done: signal.done, total: signal.total }) : ""; } diff --git a/client/content-card.tsx b/client/content-card.tsx index 5c779d64..0f30b1fa 100644 --- a/client/content-card.tsx +++ b/client/content-card.tsx @@ -25,6 +25,7 @@ export function ContentCard({ after, defaultOpen = false, openWhen, + stripe, children, }: { footer?: ReactNode; @@ -33,11 +34,13 @@ export function ContentCard({ defaultOpen?: boolean; /** Expands whenever this changes to a value (a search hit on the card); Show less still collapses it. */ openWhen?: unknown; + /** A 3 pt left edge in this color (the Memory page's kind). */ + stripe?: string; children: ReactNode; }) { const { colors } = useTheme(); return ( - + {children} diff --git a/client/drawer.tsx b/client/drawer.tsx index c28e9461..060b8c79 100644 --- a/client/drawer.tsx +++ b/client/drawer.tsx @@ -1,7 +1,7 @@ import { useCardDetailQuery, useTaskDetail } from "./card-detail-query"; import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { isNoMoves, nextMovesAction } from "../shared/next-moves"; -import { workpadCheckedAction, workpadEnabled, workpadUpdatedAction } from "../shared/workpad"; +import { workpadCheckedAction, workpadUpdatedAction } from "../shared/workpad"; import type { PaseoAgent } from "@getpaseo/client"; import { Pressable, View } from "react-native"; import { drawerNeedsBoard } from "./query-activity"; @@ -65,7 +65,6 @@ import { TaskProgress } from "./task-progress"; import { TaskUsage } from "./task-usage"; import { SessionMarkdown } from "./session-content"; import { TaskIdeas } from "./task-ideas"; -import { TaskMemorySection } from "./memory"; import { LinkOrigin, TaskLinks } from "./task-links"; import { TaskArtifacts } from "./task-artifacts"; import { TaskWorkpad } from "./task-workpad"; @@ -102,7 +101,7 @@ function TaskDrawerView({ startRequest, startIdea, onAllIdeas, - onMemoryPage, + guardrails = false, onFilterTag, escapeRequest, attention, @@ -143,8 +142,8 @@ function TaskDrawerView({ startIdea?: Idea; /** Opens the project Ideas page showing only this card (IDEAS-INBOX-SPEC §5). */ onAllIdeas?(taskId: string): void; - /** Opens the project Memory page (PROJECT-MEMORY-SPEC §8.2). */ - onMemoryPage?(): void; + /** Guardrails on (Settings and the project switch): the Workpad shows what this card was sent back for (GUARDRAILS-SPEC §7.1). */ + guardrails?: boolean; /** Adds a tag to the board filter (CARD-TAGS-SPEC §6.3). */ onFilterTag?(tag: string): void; /** Changes on each board Esc shortcut: leave the session preview, else close. */ @@ -734,9 +733,7 @@ function TaskDrawerView({ ); case "workpad": return ( - workpadEnabled(project) && ( - - ) + ); case "artifacts": return ( @@ -830,8 +827,6 @@ function TaskDrawerView({ section={sectionStates.ideas} /> )} - {/* Project memory proposed from this card, right after its ideas (PROJECT-MEMORY-SPEC §8.2). */} - {onMemoryPage && } ); case "links": @@ -1319,6 +1314,8 @@ const activityText: Record = localizedLabels({ progress_reported: msg("Reported progress"), idea_edited: msg("Edited the idea"), idea_removed: msg("Removed the idea"), + review_started: msg("Started the auto review"), + review_finished: msg("Finished the auto review"), "agent.turn_started": msg("Started a turn"), "agent.turn_ended": msg("Finished a turn"), }); @@ -1365,7 +1362,7 @@ function ActivityItem({ {!!event.rule && ( - {t("Auto")} + {event.rule === "R1" || event.rule === "R2" ? t("Auto review") : t("Auto")} )} {!!event.note && !isNoMoves(event) && ( diff --git a/client/forms.tsx b/client/forms.tsx index 8a1e5f7a..2f13b4cf 100644 --- a/client/forms.tsx +++ b/client/forms.tsx @@ -4,6 +4,7 @@ import { useBoardView } from "./board-view"; import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { View } from "react-native"; +import { useToast } from "@getpaseo/plugin/client/react-native"; import { useSettled } from "./motion"; import { perfLap } from "./perf"; import { Modal } from "./modal"; @@ -34,12 +35,15 @@ import { newMessageId, rememberSentImages } from "./sent-images"; import { DirectoryChooser } from "./directory-picker"; import { t, tn } from "../shared/i18n"; import { tagValues } from "../shared/tags"; +import { autoReviewOf } from "../shared/auto-review"; +import { AutoReviewSection, autoReviewPatch, type ReviewEdit } from "./review-settings"; import { TagEditor } from "./tag-input"; import { useStableCallback } from "./stable"; import { ExternalChip, ExternalPicker, connectionsByFamily, familyNames, pickKey, useConnections } from "./connectors"; import { BrandIcon } from "./brand-icons"; import { connectorFamilies, connectorLimits, familyOf, type ConnectorFamily, type ConnectorProvider, type ExternalSummary } from "../shared/connectors"; +const newProjectReview: ReviewEdit = { on: true, rounds: "2" }; const sameList = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((v, i) => v === b[i]); export function ProjectForm({ @@ -49,8 +53,11 @@ export function ProjectForm({ onShowArchived, onClose, onSaved, + reviewOn, }: { project?: Project; + /** Opened from What's new to turn Auto review on: the section first, its switch on (GUARDRAILS-SPEC §10). */ + reviewOn?: boolean; /** Live cards per lane, so lanes in use can't be turned off. */ laneCounts?: Partial>; /** The board's Show archived view setting; edited here, stored with Save. */ @@ -61,6 +68,7 @@ export function ProjectForm({ onSaved: (id: string, restored?: { restored: number; title: string }) => void; }) { const theme = useTheme(); + const toast = useToast(); const [project] = useState(initialProject); const create = useRpc(rpc.createProject), createLinked = useRpc(rpc.createLinkedProject), @@ -81,6 +89,8 @@ export function ProjectForm({ [workpad, setWorkpad] = useState(project?.workpad !== false), [workflowInsights, setWorkflowInsights] = useState(project?.workflowInsights !== false), [memory, setMemory] = useState(project?.memory !== false), + // A new project starts with Auto review on (GUARDRAILS-SPEC §8, D1). + [review, setReview] = useState(() => (project ? { on: autoReviewOf(project).enabled, rounds: String(autoReviewOf(project).maxRounds) } : newProjectReview)), [agentModel, setAgentModel] = useState(project?.agentModel ?? null), [archivedShown, setArchivedShown] = useState(!!showArchived), [lanes, setLanes] = useState(() => projectLanes(project)), @@ -104,11 +114,13 @@ export function ProjectForm({ staleTime: 0, }); const restoring = !!found && !found.conflict && !startEmpty; + const reviewSection = ; async function save() { setPending(true); setError(""); try { if (project) { + const stored = autoReviewOf(project); let saved = await update({ id: project.id, revision: project.revision, @@ -118,6 +130,7 @@ export function ProjectForm({ ...(workpad !== (project.workpad !== false) ? { workpad } : {}), ...(workflowInsights !== (project.workflowInsights !== false) ? { workflowInsights } : {}), ...(memory !== (project.memory !== false) ? { memory } : {}), + ...(review.on !== stored.enabled || Number(review.rounds) !== stored.maxRounds ? { autoReview: autoReviewPatch(review) } : {}), ...(JSON.stringify(lanes) !== JSON.stringify(projectLanes(project)) ? { lanes } : {}), ...(JSON.stringify(agentModel) !== JSON.stringify(project.agentModel ?? null) ? { agentModel } : {}), }); @@ -141,6 +154,12 @@ export function ProjectForm({ primaryPath, ...(source === "native" && nativeId ? { paseoProjectId: nativeId } : {}), }); + // projects.create turns Auto review on with 2 rounds; anything else is saved right after. + // The project exists now: a failed follow-up write must not leave the form open for a second Create. + if (review.on !== newProjectReview.on || review.rounds !== newProjectReview.rounds) + await update({ id: result.id, revision: result.revision, autoReview: autoReviewPatch(review) }).catch((e) => + toast.error(t("Project created, but Auto review was not saved: {error}", { error: message(e) })), + ); onSaved(result.id); } catch (e) { setError(message(e)); @@ -159,6 +178,7 @@ export function ProjectForm({ + {project && reviewOn && reviewSection} {!restoring && } {!restoring && ( )} + {!restoring && !(project && reviewOn) && reviewSection} {project && ( diff --git a/client/guardrails.tsx b/client/guardrails.tsx new file mode 100644 index 00000000..0695657d --- /dev/null +++ b/client/guardrails.tsx @@ -0,0 +1,441 @@ +import { usePerfRender } from "./perf"; +import { memo, useMemo, useState, type ReactNode } from "react"; +import { Pressable, View } from "react-native"; +import { useRpc } from "@getpaseo/plugin/client"; +import { copyText, Icon, ScrollView, useToast } from "@getpaseo/plugin/client/react-native"; +import { useQueryClient } from "@tanstack/react-query"; +import * as rpc from "../shared/rpc"; +import { taskTypeLabels, type ChangeSize, type Project, type Status, type Task } from "../shared/model"; +import { agentsRule, memoryLimits, type MemoryKind } from "../shared/memory"; +import { guardrailLimits, type GuardrailEntry, type GuardrailKind, type GuardrailState } from "../shared/guardrails"; +import { joinCommand, splitCommand } from "../shared/auto-review"; +import { ContentCard, CardBody, CardHeadline, CardMeta, CardMetaRow, CardText, SourceLine } from "./content-card"; +import { eventPoint, useMenuOpener, type MenuEntry } from "./context-menu"; +import { Chevron, Collapse } from "./disclosure"; +import { Modal } from "./modal"; +import { Enter } from "./motion"; +import { keepPageRows, usePageRows } from "./page-rows"; +import { guardrailCountsKey, guardrailsKey, useGuardrailsQuery } from "./queries"; +import { IdeaRow, useExitList } from "./task-ideas"; +import { Actions, Button, Choices, Field, IconButton, Label, Notice, Theme, font, message, noFocusRing, pageBarHeight, radius, space, tint, touch, useTheme, verticalContent, verticalScroll } from "./ui"; +import { t, msg } from "../shared/i18n"; + +/** The Guardrails page (GUARDRAILS-SPEC §7.4): the project's commands and learned guidelines. Notes have the Memory page. */ + +const sizeLabels: Record = { s: msg("Small changes"), m: msg("Medium changes"), l: msg("Large changes") }; +/** Why an entry stopped (§6.7, §3.4, §7.3); the daemon writes these English reasons, translated at render. */ +const reasonLabels: Record = { + "misfired twice in 30 days": msg("Misfired twice in 30 days"), + "never caught anything": msg("Caught nothing in 20 reviews"), + "unused for 90 days": msg("Unused for 90 days"), + "cannot run": msg("Could not run 3 times"), + "script missing": msg("The script is gone"), + displaced: msg("Pushed out by a newer one"), + merged: msg("Merged into another"), + superseded: msg("Replaced"), + promoted: msg("Moved to AGENTS.md"), + dismissed: msg("Dismissed"), +}; +export const reasonText = (reason: string | undefined) => (reason ? (reasonLabels[reason] ? t(reasonLabels[reason]) : reason) : ""); + +type Segment = "command" | "guideline"; +const live = (e: GuardrailEntry) => e.state === "active" || e.state === "observing"; +const list = (value: string) => [...new Set(value.split(",").map((s) => s.trim()).filter(Boolean))]; + +type CommandDraft = { name: string; command: string; timeout: string }; +type GuidelineDraft = { text: string; body: string }; +export type NoteDraft = { kind: MemoryKind; title: string; body: string; tags: string; keywords: string; pinned: boolean }; + +/** Every guardrail write, refreshing the page and the overview chip afterwards; failures (GUARDRAIL_CONFLICT included) toast. */ +export function useGuardrailActions(projectId: string) { + const client = useQueryClient(); + const toast = useToast(); + const createRpc = useRpc(rpc.createGuardrail), + updateRpc = useRpc(rpc.updateGuardrail), + deleteRpc = useRpc(rpc.deleteGuardrail); + const [busy, setBusy] = useState(""); + const refresh = () => { + void client.invalidateQueries({ queryKey: guardrailsKey(projectId) }); + void client.invalidateQueries({ queryKey: guardrailCountsKey }); + }; + const run = async (id: string, work: () => Promise, done?: string) => { + setBusy(id); + try { + await work(); + if (done) toast.show(done, { variant: "success" }); + return true; + } catch (e) { + toast.show(message(e), { variant: "error" }); + return false; + } finally { + setBusy(""); + refresh(); + } + }; + const update = (e: GuardrailEntry, patch: Omit[0], "id" | "revision">) => run(e.id, () => updateRpc({ id: e.id, revision: e.revision, ...patch })); + return { + busy, + addCommand: (d: CommandDraft) => + run("new", () => createRpc({ projectId, kind: "command", text: d.name.trim(), argv: splitCommand(d.command), ...(d.timeout ? { timeoutSec: Number(d.timeout) } : {}) })), + addNote: (d: NoteDraft) => + run("new", () => + createRpc({ projectId, kind: "note", noteKind: d.kind, text: d.title.trim(), body: d.body.trim(), tags: list(d.tags).map((s) => s.toLowerCase()).slice(0, memoryLimits.tags), keywords: list(d.keywords).slice(0, memoryLimits.keywords), pinned: d.pinned }), + ), + saveCommand: (e: GuardrailEntry, d: CommandDraft) => update(e, { text: d.name.trim(), argv: splitCommand(d.command), ...(d.timeout ? { timeoutSec: Number(d.timeout) } : {}) }), + saveGuideline: (e: GuardrailEntry, d: GuidelineDraft) => update(e, { text: d.text.trim(), body: d.body.trim() }), + saveNote: (e: GuardrailEntry, d: NoteDraft) => + update(e, { text: d.title.trim(), body: d.body.trim(), tags: list(d.tags).map((s) => s.toLowerCase()).slice(0, memoryLimits.tags), keywords: list(d.keywords).slice(0, memoryLimits.keywords), pinned: d.pinned }), + pin: (e: GuardrailEntry, pinned: boolean) => update(e, { pinned }), + setState: (e: GuardrailEntry, state: GuardrailState, stateReason?: string) => update(e, { state, ...(stateReason ? { stateReason } : {}) }), + remove: (e: GuardrailEntry) => run(e.id, () => deleteRpc({ id: e.id, revision: e.revision }), t("Deleted.")), + copyRule: async (e: GuardrailEntry) => { + try { + await copyText(agentsRule({ title: e.text, body: e.body || "" })); + toast.show(t("Copied. Paste it into the repository's AGENTS.md, then choose Done, stop injecting."), { variant: "success" }); + } catch (err) { + toast.show(message(err), { variant: "error" }); + } + }, + }; +} +export type GuardrailActions = ReturnType; + +/** Back into use: a command that has not finished observing goes back to observing (§4.5). */ +const resumed = (e: GuardrailEntry): GuardrailState => (e.kind === "command" && e.observe && e.observe.reviews < guardrailLimits.observeReviews ? "observing" : "active"); + +/** One guardrail as a content card, with its kind's facts and menu (§7.4). */ +const GuardrailCard = memo(function GuardrailCard({ + entry, + actions, + disabled, + cards, + nameOf, + onOpenTask, + onEditorOpen, +}: { + entry: GuardrailEntry; + actions: GuardrailActions; + disabled: boolean; + cards: ReadonlyMap; + nameOf(status: Status): string; + onOpenTask(id: string): void; + /** Keep this row mounted while background results are reordered. */ + onEditorOpen(id: string): void; +}) { + const { colors } = useTheme(); + const openMenu = useMenuOpener(); + const [editing, setEditing] = useState(false); + // Once edited, the card rises back in when the editor closes; on first render it just sits there. + const [edited, setEdited] = useState(false); + const [deleting, setDeleting] = useState(false); + const busy = disabled || actions.busy === entry.id; + const edit = () => { + onEditorOpen(entry.id); + setEditing(true); + }; + const closeEditor = () => { + setEdited(true); + setEditing(false); + }; + const saved = (ok: boolean) => ok && closeEditor(); + if (editing) + return ( + + {entry.kind === "command" ? ( + saved(await actions.saveCommand(entry, d))} /> + ) : ( + saved(await actions.saveGuideline(entry, d))} /> + )} + + ); + const archived = entry.state === "archived"; + const paused = entry.state === "paused"; + const items: MenuEntry[] = []; + if (!archived) items.push({ id: "edit", label: t("Edit"), icon: "Pencil", onPress: edit }); + if (live(entry)) items.push({ id: "pause", label: t("Pause"), icon: "Pause", onPress: () => void actions.setState(entry, "paused") }); + if (paused || archived) items.push({ id: "resume", label: archived ? t("Restore") : t("Resume"), icon: archived ? "ArchiveRestore" : "Play", onPress: () => void actions.setState(entry, resumed(entry)) }); + if (entry.kind === "guideline" && !archived) items.push({ id: "archive", label: t("Archive"), icon: "Archive", onPress: () => void actions.setState(entry, "archived") }); + items.push({ kind: "separator", id: "s" }, { id: "delete", label: t("Delete…"), icon: "Trash2", danger: true, onPress: () => setDeleting(true) }); + const footer = openMenu ? ( + openMenu(eventPoint(event), entry.text, items)} /> + ) : ( + + {items.flatMap((item) => + item.kind !== "separator" && item.kind !== "label" ? [ + , + ] : [], + )} + + ); + const sourceIds = [...new Set([entry.source?.taskId, ...entry.sources.map((s) => s.taskId)].filter((id): id is string => !!id))].slice(0, entry.kind === "guideline" ? 3 : 1); + const after = sourceIds.length ? <>{sourceIds.map((id) => )} : undefined; + const reason = paused || archived ? reasonText(entry.stateReason) : ""; + const quote = entry.kind === "guideline" ? entry.sources.find((s) => s.quote)?.quote : undefined; + const c = entry.counts; + const content = ( + + + + {entry.state === "observing" && ( + } text={t("observing {done}/{total}", { done: Math.min(entry.observe?.reviews ?? 0, guardrailLimits.observeReviews), total: guardrailLimits.observeReviews })} /> + )} + {entry.kind === "guideline" && entry.scope && entry.scope.types !== "all" && taskTypeLabels[type]).join(", ")} />} + {entry.kind === "guideline" && entry.scope && entry.scope.sizes !== "all" && t(sizeLabels[size])).join(", ")} />} + {!!reason && } + + {!!quote && ( + + + + )} + {quote ? : {entry.text}} + {entry.kind === "command" && !!entry.argv && } + {!!entry.body && {entry.body}} + {entry.kind === "guideline" && ( + + )} + {entry.kind === "command" && c.reviews > 0 && } + + + ); + return ( + <> + {edited ? {content} : content} + {deleting && ( + setDeleting(false)} + onConfirm={async () => { + await actions.remove(entry); + setDeleting(false); + }} + /> + )} + + ); +}); + +export function ConfirmDialog({ title, text, onClose, onConfirm }: { title: string; text: string; onClose(): void; onConfirm(): Promise }) { + const theme = useTheme(); + const [pending, setPending] = useState(false); + return ( + !open && !pending && onClose()}> + + + + + + + + + + + + + ); +} + +/** The inline editor frame shared by commands, guidelines and the Memory page. */ +export function Editor({ title, submit, valid, onCancel, onSubmit, children }: { title: string; submit: string; valid: boolean; onCancel(): void; onSubmit(): Promise; children: ReactNode }) { + const { colors } = useTheme(); + const [pending, setPending] = useState(false); + const go = async () => { + if (!valid || pending) return; + setPending(true); + try { + await onSubmit(); + } finally { + setPending(false); + } + }; + return ( + + + {children} + + + + + + ); +} + +function CommandEditor({ title, submit, initial, onCancel, onSubmit }: { title: string; submit: string; initial: CommandDraft; onCancel(): void; onSubmit(d: CommandDraft): Promise }) { + const [draft, setDraft] = useState(initial); + const set = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); + const timeout = Number(draft.timeout); + const valid = !!draft.name.trim() && splitCommand(draft.command).length > 0 && (!draft.timeout || (Number.isInteger(timeout) && timeout >= guardrailLimits.minTimeout && timeout <= guardrailLimits.maxTimeout)); + return ( + onSubmit(draft)}> + set({ name })} placeholder="typecheck" autoFocus /> + set({ command })} placeholder="npm run typecheck" /> + set({ timeout: value.replace(/\D/g, "") })} placeholder="600" /> + + ); +} + +function GuidelineEditor({ initial, onCancel, onSubmit }: { initial: GuidelineDraft; onCancel(): void; onSubmit(d: GuidelineDraft): Promise }) { + const [draft, setDraft] = useState(initial); + return ( + onSubmit(draft)}> + setDraft((d) => ({ ...d, text }))} multiline autoFocus /> + setDraft((d) => ({ ...d, body }))} multiline /> + + ); +} + +const stateRank: Record = { active: 0, observing: 0, paused: 1, archived: 2 }; + +/** The project's Guardrails page (§7.4). Replaces the board area while open, like Ideas and Insights. */ +export const GuardrailsPage = memo(function GuardrailsPage({ + project, + tasks, + compact, + nameOf, + onOpenTask, + onBack, +}: { + project: Project; + /** The project's cards, archived included. */ + tasks: readonly Task[]; + compact: boolean; + nameOf(status: Status): string; + onOpenTask(id: string): void; + onBack(): void; +}) { + usePerfRender("project.guardrails.render"); + const { colors } = useTheme(); + const query = useGuardrailsQuery(project.id, true); + const view = query.data; + const actions = useGuardrailActions(project.id); + const [segment, setSegment] = useState("guideline"); + const [adding, setAdding] = useState(false); + const [archivedOpen, setArchivedOpen] = useState(false); + const entries = view?.entries ?? noEntries; + const cards = useMemo(() => new Map(tasks.map((x) => [x.id, x])), [tasks]); + const disabled = !!project.archivedAt || !view?.enabled; + const ofKind = (kind: GuardrailKind) => entries.filter((e) => e.kind === kind); + const liveCount = (kind: GuardrailKind) => ofKind(kind).filter(live).length; + const shown = ofKind(segment) + .sort((a, b) => stateRank[a.state] - stateRank[b.state] || Number(!!b.pinned) - Number(!!a.pinned) || b.stateAt.localeCompare(a.stateAt)); + const groups = { active: shown.filter(live), paused: shown.filter((e) => e.state === "paused"), archived: shown.filter((e) => e.state === "archived") }; + const listed = [...groups.active, ...groups.paused, ...(archivedOpen ? groups.archived : [])]; + const pageRows = usePageRows(JSON.stringify([project.id, segment, archivedOpen]), listed.length, compact); + const [keptRows, setKeptRows] = useState>(() => new Set()); + const keepRow = (id: string) => setKeptRows((previous) => (previous.has(id) ? previous : new Set([...previous, id]))); + const windowed = new Set(keepPageRows(listed, pageRows.limit, keptRows).map((e) => e.id)); + const inWindow = (rows: readonly GuardrailEntry[]) => rows.filter((e) => windowed.has(e.id)); + const remaining = listed.length - windowed.size; + const card = (e: GuardrailEntry, leaving: boolean) => ( + + ); + return ( + + + + + + + + + + + + {query.error && {message(query.error)}} + {view && !view.enabled && {t("Guardrails are off for this project. Turn them on in the project's settings; what is here is kept.")}} + { + setSegment(next); + setAdding(false); + }} + /> + {segment === "command" && !disabled && !adding && ( + + + + )} + + setAdding(false)} onSubmit={async (d) => (await actions.addCommand(d)) && setAdding(false)} /> + + + + + + + + } + /> + } /> + {!!groups.archived.length && ( + + setArchivedOpen(!archivedOpen)} + style={{ ...noFocusRing, flexDirection: "row", alignItems: "center", gap: space[1], minHeight: touch ? 36 : 24 }} + > + + + + + + + + )} + + + {remaining > 0 && } + + + + ); +}); +const noEntries: GuardrailEntry[] = []; + +function GroupTitle({ title }: { title: string }) { + return ; +} + +/** + * A list of guardrail cards that behaves like the Ideas list: a card that leaves it (paused, archived, restored, + * deleted) collapses away in place, and one that arrives rises in. `before` (a title) shows while any row is on screen. + */ +function GuardrailRows({ entries, render, before, empty }: { entries: readonly GuardrailEntry[]; render(entry: GuardrailEntry, leaving: boolean): ReactNode; before?: ReactNode; empty?: ReactNode }) { + const rows = useExitList(entries); + if (!rows.length) return <>{empty}; + const body = rows.map((row) => ( + + {render(row.item, row.leaving)} + + )); + return before ? {before}{body} : {body}; +} diff --git a/client/insights.tsx b/client/insights.tsx index 92ed6b44..a5b57139 100644 --- a/client/insights.tsx +++ b/client/insights.tsx @@ -2,12 +2,12 @@ import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "reac import { Pressable, View } from "react-native"; import { Icon, ScrollView, useToast } from "@getpaseo/plugin/client/react-native"; import { useRpc } from "@getpaseo/plugin/client"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import * as rpc from "../shared/rpc"; import type { Project, Status, Task } from "../shared/model"; import { addDays } from "../shared/insights"; import { ACTIVITY_WEEKS, activityLevel, type Activity } from "../shared/activity"; -import { MIN_CARDS, WINDOW_DAYS, type CardTokens, type WorkflowStats } from "../shared/workflow-stats"; +import { type CardTokens, type WorkflowStats } from "../shared/workflow-stats"; import type { DigestItem } from "../shared/digest"; import { compactDuration, compactNumber, percent } from "../shared/usage"; import { complexityLevel, complexityShare, type DayCard } from "../shared/day-detail"; @@ -32,6 +32,7 @@ export const InsightsPage = memo(function InsightsPage({ onBack, onCreateFromFinding, onOpenSettings, + onOpenGuardrails, }: { project: Project; tasks: readonly Task[]; @@ -45,6 +46,8 @@ export const InsightsPage = memo(function InsightsPage({ onCreateFromFinding(finding: Finding): void; /** Settings → AI features, where Skills (workflow insights) are configured. */ onOpenSettings(): void; + /** The Guardrails page, when guardrails are on for the project (GUARDRAILS-SPEC §7.2). */ + onOpenGuardrails?(): void; }) { const { colors } = useTheme(); const insights = useInsightsQuery(project.id, revision, active); @@ -73,6 +76,7 @@ export const InsightsPage = memo(function InsightsPage({ ) : ( <> +
@@ -668,7 +672,7 @@ function OtherSuggestions({ projectId, view, titleOf, onOpenTask, onCreate }: { const [failed, setFailed] = useState(""); if (!view) return null; const findings = view.findings - .filter((f) => !isDraft(f)) + .filter((f) => !isDraft(f) && f.target in targetLabels) .sort((a, b) => confidenceOrder[a.confidence] - confidenceOrder[b.confidence] || b.createdAt.localeCompare(a.createdAt)); const active = findings.filter((f) => f.status !== "dismissed"); const dismissed = findings.filter((f) => f.status === "dismissed"); @@ -708,24 +712,62 @@ function OtherSuggestions({ projectId, view, titleOf, onOpenTask, onCreate }: { ); } -/** Review rounds, one-pass share and tokens per card of the last 7 days, folded (§2.5). */ +// ---- The closed loop (GUARDRAILS-SPEC §7.2) ------------------------------------------------ + +/** + * The project's account at the top: deliveries in the last 14 days, the share that passed their first review and the + * misfire share (hidden while nothing was failed by judgement), then this week's guardrail changes, each opening the + * Guardrails page. Read once per opening; hidden when the daemon cannot answer. + */ +function GuardrailLedger({ projectId, active, onOpen }: { projectId: string; active: boolean; onOpen?(): void }) { + const { colors } = useTheme(); + const read = useRpc(rpc.guardrailsInsights); + const ledger = useQuery({ + queryKey: ["flowly", "insights-guardrails", projectId], + queryFn: () => read({ projectId, tz: localZone() }), + enabled: active, + staleTime: 5 * 60_000, + retry: false, + }).data; + if (!ledger || !ledger.deliveries) return null; + const numbers = [ + tn(ledger.deliveries, "{n} delivery", "{n} deliveries"), + ledger.reviewed ? t("{share} passed the first review", { share: percent(ledger.firstPass / ledger.reviewed) }) : "", + ledger.judged ? t("{share} misfires", { share: percent(ledger.misfires / ledger.judged) }) : "", + ].filter(Boolean); + const c = ledger.changes; + const changes = [ + c.learned ? tn(c.learned, "{n} guideline learned", "{n} guidelines learned") : "", + c.widened ? tn(c.widened, "{n} widened", "{n} widened") : "", + c.revived ? tn(c.revived, "{n} revived", "{n} revived") : "", + c.retired ? tn(c.retired, "{n} retired", "{n} retired") : "", + c.observing ? tn(c.observing, "{n} command observing", "{n} commands observing") : "", + ].filter(Boolean); + return ( +
+ + {!!changes.length && ( + + + {changes.map((text, i) => ( + + + + ))} + + )} +
+ ); +} + +/** Rounds and tokens per model of the last 7 days, folded; the summary line gave way to the closed loop (GUARDRAILS-SPEC §7.2). */ function WorkflowNumbers({ projectId, flow, active }: { projectId: string; flow: WorkflowStats; active: boolean }) { - const [open, setOpen] = useState(true); - const tokens = useTokensQuery(projectId, active); - const perCard = tokens.data?.perCard; - const summary = - flow.finished < MIN_CARDS - ? tn(WINDOW_DAYS, "Too few cards finished in the last {n} day", "Too few cards finished in the last {n} days") - : [ - tn(flow.finished, "Last 7 days: {n} card finished", "Last 7 days: {n} cards finished"), - tn(flow.rounds ?? 0, "median {n} review round", "median {n} review rounds", { n: fmt(flow.rounds) }), - t("{share} in one pass", { share: flow.onePassShare === null ? "—" : percent(flow.onePassShare) }), - tokens.error ? "" : t("{n} tokens per card", { n: tokens.data ? (perCard == null ? "—" : compactNumber(perCard)) : "…" }), - ] - .filter(Boolean) - .join(" · "); + const [open, setOpen] = useState(false); + const tokens = useTokensQuery(projectId, active && open); return ( - + {flow.models.length > 0 ? ( @@ -892,7 +934,8 @@ function DigestLine({ item, title, onOpenTask }: { item: DigestItem; title: stri ); } -const targetLabels: Record = { +/** Auto review findings are gone (GUARDRAILS-SPEC §7.2); any left over are not listed. */ +const targetLabels: Partial> = { paseo_script: msg("paseo.json script"), worktree_setup: msg("Worktree setup"), repo_tool: msg("Repo tool"), @@ -972,7 +1015,7 @@ function FindingRow({ diff --git a/client/memory-layout.test.ts b/client/memory-layout.test.ts new file mode 100644 index 00000000..4f6f1738 --- /dev/null +++ b/client/memory-layout.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { emptyCounts, type GuardrailEntry } from "../shared/guardrails"; +import { estimateHeight, gridColumns, placeMasonry, sortNotes } from "./memory-layout"; + +const note = (id: string, patch: Partial = {}): GuardrailEntry => ({ + id, + projectId: "p", + kind: "note", + noteKind: "decision", + text: id, + state: "active", + stateAt: "2026-10-01T00:00:00.000Z", + counts: emptyCounts(), + sources: [], + by: "agent", + revision: 1, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + ...patch, +}); +const used = (...tasks: [string, string][]) => ({ ...emptyCounts(), usedBy: tasks.map(([taskId, at]) => ({ taskId, at })), usedAt: tasks.at(-1)?.[1] }); + +describe("Memory page order (GUARDRAILS-SPEC §7.4)", () => { + const a = note("a", { createdAt: "2026-10-03T00:00:00.000Z" }); + const b = note("b", { createdAt: "2026-10-02T00:00:00.000Z", counts: used(["t1", "2026-10-05T00:00:00.000Z"], ["t2", "2026-10-06T00:00:00.000Z"]) }); + const c = note("c", { createdAt: "2026-10-01T00:00:00.000Z", counts: used(["t1", "2026-10-09T00:00:00.000Z"]) }); + const pinned = note("pinned", { createdAt: "2026-09-01T00:00:00.000Z", pinned: true }); + it("puts pinned first, then newest, most used or recently used", () => { + expect(sortNotes([c, a, pinned, b], "newest").map((e) => e.id)).toEqual(["pinned", "a", "b", "c"]); + expect(sortNotes([c, a, pinned, b], "used").map((e) => e.id)).toEqual(["pinned", "b", "c", "a"]); + expect(sortNotes([c, a, pinned, b], "recent").map((e) => e.id)).toEqual(["pinned", "c", "b", "a"]); + }); + it("counts a card that used an entry twice once", () => { + const twice = note("twice", { counts: used(["t1", "2026-10-05T00:00:00.000Z"], ["t1", "2026-10-06T00:00:00.000Z"]) }); + expect(sortNotes([twice, b], "used").map((e) => e.id)).toEqual(["b", "twice"]); + }); +}); + +describe("Memory grid", () => { + it("uses columns at least 340 pt wide, two or three", () => { + expect([400, 679, 680, 1019, 1020, 1120, 2000].map(gridColumns)).toEqual([2, 2, 2, 2, 3, 3, 3]); + }); + it("places each entry into the shortest column and keeps earlier placements", () => { + const items = [{ id: "1", h: 300 }, { id: "2", h: 100 }, { id: "3", h: 100 }, { id: "4", h: 100 }]; + const ids = (cols: { id: string }[][]) => cols.map((col) => col.map((x) => x.id)); + expect(ids(placeMasonry(items, 2, (x) => x.h))).toEqual([["1"], ["2", "3", "4"]]); + // A new entry on top goes to the shortest column; the others stay where they were. + const previous = new Map([["1", 0], ["2", 1], ["3", 1], ["4", 1]]); + expect(ids(placeMasonry([{ id: "0", h: 100 }, ...items], 2, (x) => x.h, previous))).toEqual([["0", "1"], ["2", "3", "4"]]); + // A placement past the current column count is placed again. + expect(ids(placeMasonry(items, 2, (x) => x.h, new Map([["1", 3]])))).toEqual([["1"], ["2", "3", "4"]]); + }); + it("estimates a longer body taller, up to the six-line fold", () => { + const short = estimateHeight(note("s", { body: "x" }), 340); + const long = estimateHeight(note("l", { body: "x".repeat(400) }), 340); + expect(long).toBeGreaterThan(short); + expect(estimateHeight(note("xl", { body: "x".repeat(4000) }), 340)).toBe(long); + }); + it("never folds the title, and counts the tags/usage line only when there is one", () => { + const one = estimateHeight(note("t"), 340); + expect(estimateHeight(note("t".repeat(400)), 340)).toBeGreaterThan(estimateHeight(note("t".repeat(200)), 340)); + expect(estimateHeight(note("t", { tags: ["api"] }), 340)).toBeGreaterThan(one); + expect(estimateHeight(note("t", { counts: used(["t1", "2026-10-05T00:00:00.000Z"]) }), 340)).toBeGreaterThan(one); + }); +}); diff --git a/client/memory-layout.ts b/client/memory-layout.ts new file mode 100644 index 00000000..8ed7794f --- /dev/null +++ b/client/memory-layout.ts @@ -0,0 +1,52 @@ +import type { GuardrailEntry } from "../shared/guardrails"; +import type { MemorySort } from "../shared/settings"; + +/** The Memory page's ordering and grid (GUARDRAILS-SPEC §7.4). Pure: no React. */ + +/** Grid columns for the page's width: columns at least 340 pt wide, two or three. */ +export const gridColumns = (width: number) => Math.max(2, Math.min(3, Math.floor(width / 340))); + +const usedCards = (e: GuardrailEntry) => new Set(e.counts.usedBy.map((u) => u.taskId)).size; + +/** Pinned first, then by the chosen order; ties go to the newer entry. */ +export function sortNotes(entries: readonly GuardrailEntry[], sort: MemorySort): GuardrailEntry[] { + const key = (e: GuardrailEntry) => (sort === "used" ? usedCards(e) : 0); + const at = (e: GuardrailEntry) => (sort === "recent" ? e.counts.usedAt || "" : ""); + return [...entries].sort( + (a, b) => Number(!!b.pinned) - Number(!!a.pinned) || key(b) - key(a) || at(b).localeCompare(at(a)) || b.createdAt.localeCompare(a.createdAt), + ); +} + +/** + * About how tall a card renders in a column this wide: padding and stripe, the kind row, the full title, + * the body folded to six lines, the source line and the tags/usage line. + */ +export function estimateHeight(e: GuardrailEntry, width: number) { + const inner = width - 34; + const lines = (text: string, charWidth: number) => { + const chars = Math.max(20, Math.floor(inner / charWidth)); + return text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / chars)), 0); + }; + const gap = 8; + const title = gap + lines(e.text, 7.5) * 19; + const body = e.body ? gap + Math.min(6, lines(e.body, 6.5)) * 18 : 0; + const source = e.source?.taskId || e.sources.length ? gap + 28 : 0; + const facts = e.tags?.length || e.counts.usedAt ? gap + 16 : 0; + return 34 + 24 + title + body + source + facts; +} + +/** + * Puts each entry, in order, into the currently shortest column. An entry placed before keeps its column + * (`previous`) so a background refresh does not reshuffle the grid or remount a card being edited. + */ +export function placeMasonry(items: readonly T[], columns: number, height: (item: T) => number, previous?: ReadonlyMap): T[][] { + const out: T[][] = Array.from({ length: columns }, () => []); + const sums = new Array(columns).fill(0); + for (const item of items) { + const kept = previous?.get(item.id); + const column = kept !== undefined && kept < columns ? kept : sums.indexOf(Math.min(...sums)); + out[column].push(item); + sums[column] += height(item); + } + return out; +} diff --git a/client/memory.tsx b/client/memory.tsx index 65b70c6e..3fe34e4f 100644 --- a/client/memory.tsx +++ b/client/memory.tsx @@ -1,172 +1,61 @@ import { usePerfRender } from "./perf"; -import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { Pressable, View } from "react-native"; -import { useRpc } from "@getpaseo/plugin/client"; -import { copyText, Icon, ScrollView, useToast } from "@getpaseo/plugin/client/react-native"; -import { useQueryClient } from "@tanstack/react-query"; -import * as rpc from "../shared/rpc"; +import { memo, useMemo, useRef, useState, type ReactNode } from "react"; +import { Animated, Pressable, Text, View } from "react-native"; +import { Icon, ScrollView } from "@getpaseo/plugin/client/react-native"; +import type { PluginTheme } from "@getpaseo/plugin"; import type { Project, Status, Task } from "../shared/model"; -import { - agentsRule, - isStale, - isWidelyUsed, - leastUsed, - memoryKinds, - memoryLimits, - recentCards, - type MemoryEntry, - type MemoryKind, - type MemoryView, -} from "../shared/memory"; -import { ContentCard, CardBody, CardHeadline, CardMeta, CardMetaRow, CardText, SourceLine } from "./content-card"; -export { SourceLine }; -import { eventPoint, useMenuOpener } from "./context-menu"; -import { Chevron, Collapse, Section } from "./disclosure"; +import { agentsRule, memoryKinds, type MemoryKind } from "../shared/memory"; +import type { GuardrailEntry, GuardrailState } from "../shared/guardrails"; +import type { BoardView, MemorySort } from "../shared/settings"; +import { cardFrameStyle, SourceLine } from "./content-card"; +import { eventPoint, menuHandlers, useMenuOpener, type MenuEntry } from "./context-menu"; +import { Chevron, Collapse } from "./disclosure"; +import { ConfirmDialog, Editor, reasonText, useGuardrailActions, type NoteDraft } from "./guardrails"; +import { estimateHeight, gridColumns, placeMasonry, sortNotes } from "./memory-layout"; import { Modal } from "./modal"; -import { Enter } from "./motion"; +import { Enter, useMotionValue } from "./motion"; import { keepPageRows, usePageRows } from "./page-rows"; -import { memoryCountsKey, memoryKey, useMemoryQuery } from "./queries"; -import { IdeaRow, useExitList, useHeld } from "./task-ideas"; -import { Actions, AiMark, Button, Choices, CueRing, Field, IconButton, Label, Notice, TextBox, Theme, Toggle, font, message, noFocusRing, pageBarHeight, radius, space, tint, touch, useTheme, verticalContent, verticalScroll } from "./ui"; +import { useGuardrailsQuery } from "./queries"; +import { useStableCallback } from "./stable"; +import { IdeaRow, useExitList } from "./task-ideas"; +import { Actions, Button, Choices, Dot, Field, IconButton, Label, Notice, Theme, Toggle, font, message, noFocusRing, pageBarHeight, radius, space, tint, touch, useTheme, verticalContent, verticalScroll } from "./ui"; import { t, msg, tn } from "../shared/i18n"; -/** Project memory UI (PROJECT-MEMORY-SPEC §8): the Memory page, the drawer section and their shared cards. */ +/** The Memory page (GUARDRAILS-SPEC §7.4, D10): the project's `note` entries, kept apart from the Guardrails page. */ export const kindLabels: Record = { decision: msg("Decision"), convention: msg("Convention"), pitfall: msg("Pitfall"), contact: msg("Contact") }; -const kindPlural: Record = { decision: msg("Decisions"), convention: msg("Conventions"), pitfall: msg("Pitfalls"), contact: msg("Contacts") }; export const kindIcons: Record = { decision: "Gavel", convention: "ListChecks", pitfall: "TriangleAlert", contact: "Contact" }; +/** The filter chips' order. */ +const kindOrder: readonly MemoryKind[] = ["decision", "pitfall", "convention", "contact"]; +/** One host theme color per kind, for the card's left edge and its kind label. */ +export const kindColor = (colors: PluginTheme["colors"], kind: MemoryKind) => + ({ decision: colors.accent, pitfall: colors.statusWarning, convention: colors.statusSuccess, contact: colors.foregroundMuted })[kind]; +const sortLabels: Record = { newest: msg("Newest"), used: msg("Most used"), recent: msg("Recently used") }; +const sortOrder: readonly MemorySort[] = ["newest", "used", "recent"]; -type Draft = { kind: MemoryKind; title: string; body: string; tags: string; keywords: string; pinned: boolean }; -const list = (value: string) => [...new Set(value.split(",").map((s) => s.trim()).filter(Boolean))]; -const draftOf = (e?: MemoryEntry): Draft => ({ - kind: e?.kind ?? "decision", - title: e?.title ?? "", +export type MemoryPrefs = Pick; + +/** What a card can do; identities stay the same across renders so cards stay memoized. */ +type Ops = { + save(e: GuardrailEntry, d: NoteDraft): Promise; + pin(e: GuardrailEntry, pinned: boolean): Promise; + setState(e: GuardrailEntry, state: GuardrailState, reason?: string): Promise; + remove(e: GuardrailEntry): Promise; + copyRule(e: GuardrailEntry): Promise; + openTask(id: string): void; + keep(id: string): void; +}; + +const draftOf = (e?: GuardrailEntry): NoteDraft => ({ + kind: e?.noteKind ?? "decision", + title: e?.text ?? "", body: e?.body ?? "", tags: (e?.tags ?? []).join(", "), keywords: (e?.keywords ?? []).join(", "), - pinned: e?.pinned ?? false, + pinned: !!e?.pinned, }); -const editsOf = (d: Draft) => ({ kind: d.kind, title: d.title.trim(), body: d.body.trim(), tags: list(d.tags).map((s) => s.toLowerCase()).slice(0, memoryLimits.tags), keywords: list(d.keywords).slice(0, memoryLimits.keywords) }); - -/** Every memory write, refreshing the project's memory and the overview counts afterwards; failures toast. */ -export function useMemoryActions(projectId: string, view: MemoryView | undefined) { - const client = useQueryClient(); - const toast = useToast(); - const confirmRpc = useRpc(rpc.confirmMemory), - updateRpc = useRpc(rpc.updateMemory), - createRpc = useRpc(rpc.createMemory), - dismissRpc = useRpc(rpc.dismissMemory), - restoreRpc = useRpc(rpc.restoreMemory), - archiveRpc = useRpc(rpc.archiveMemory), - archiveManyRpc = useRpc(rpc.archiveStaleMemory), - deleteRpc = useRpc(rpc.deleteMemory), - retroRpc = useRpc(rpc.reviewForMemory); - const [busy, setBusy] = useState(""); - // A dismissed candidate leaves at once, before the save returns; `hidden` holds those the project's memory does not show as closed yet. - const [hidden, setHidden] = useState>(() => new Set()); - const entries = view?.entries; - useEffect(() => { - if (!hidden.size) return; - const settled = [...hidden].filter((id) => !entries?.some((e) => e.id === id && e.status === "candidate")); - if (settled.length) setHidden((current) => new Set([...current].filter((id) => !settled.includes(id)))); - }, [entries, hidden]); - /** Make room (§6.5): the action waiting for an archived entry. */ - const [room, setRoom] = useState Promise)>(null); - const refresh = () => { - void client.invalidateQueries({ queryKey: memoryKey(projectId) }); - void client.invalidateQueries({ queryKey: memoryCountsKey }); - }; - const run = async (id: string, work: () => Promise, done?: string) => { - setBusy(id); - try { - await work(); - if (done) toast.show(done, { variant: "success" }); - return true; - } catch (e) { - toast.show(message(e), { variant: "error" }); - return false; - } finally { - setBusy(""); - refresh(); - } - }; - const active = (view?.entries ?? []).filter((e) => e.status === "active").length; - /** Runs `work` now, or after the user archived one entry when the project is full (then resolves false). */ - const withRoom = async (work: () => Promise): Promise => { - if (active < memoryLimits.activePerProject) return work(); - setRoom(() => work); - return false; - }; - return { - busy, - hidden: hidden as ReadonlySet, - room, - closeRoom: () => setRoom(null), - refresh, - confirm: (e: MemoryEntry, draft?: Draft) => - withRoom(() => run(e.id, () => confirmRpc({ id: e.id, revision: e.revision, ...(draft ? { edits: editsOf(draft) } : {}) }), t("Remembered. New sessions on this project will see it."))), - save: (e: MemoryEntry, draft: Draft) => run(e.id, () => updateRpc({ id: e.id, revision: e.revision, ...editsOf(draft), pinned: draft.pinned })), - create: (draft: Draft) => withRoom(() => run("new", () => createRpc({ projectId, ...editsOf(draft), pinned: draft.pinned }), t("Remembered. New sessions on this project will see it."))), - pin: (e: MemoryEntry, pinned: boolean) => run(e.id, () => updateRpc({ id: e.id, revision: e.revision, pinned })), - dismiss: async (e: MemoryEntry) => { - setHidden((current) => new Set(current).add(e.id)); - const saved = await run(e.id, () => dismissRpc({ id: e.id, revision: e.revision })); - if (!saved) setHidden((current) => new Set([...current].filter((id) => id !== e.id))); - return saved; - }, - restore: (e: MemoryEntry) => { - const save = () => run(e.id, () => restoreRpc({ id: e.id, revision: e.revision })); - return e.status === "archived" ? withRoom(save) : save(); - }, - archive: (e: MemoryEntry, reason: "retired" | "promoted" = "retired") => run(e.id, () => archiveRpc({ id: e.id, revision: e.revision, reason })), - archiveStale: (entries: readonly MemoryEntry[]) => - run("stale", () => archiveManyRpc({ projectId, ids: entries.map((e) => ({ id: e.id, revision: e.revision })) }), tn(entries.length, "Archived {n} memory.", "Archived {n} memories.")), - remove: (e: MemoryEntry) => run(e.id, () => deleteRpc({ id: e.id, revision: e.revision }), t("Memory deleted.")), - copyRule: async (e: MemoryEntry) => { - try { - await copyText(agentsRule(e)); - toast.show(t("Copied. Paste it into the repository's AGENTS.md, then choose Done, stop injecting."), { variant: "success" }); - } catch (err) { - toast.show(message(err), { variant: "error" }); - } - }, - review: (taskId: string) => run(taskId, () => retroRpc({ taskId })), - /** After Make room archived one: the waiting action runs. */ - madeRoom: async (e: MemoryEntry) => { - const next = room; - setRoom(null); - if (await run(e.id, () => archiveRpc({ id: e.id, revision: e.revision, reason: "retired" })) && next) await next(); - }, - }; -} -export type MemoryActions = ReturnType; -/** One memory as a content card: candidates get Dismiss / Edit / Confirm, confirmed ones Edit and a menu. */ -export const MemoryCard = memo(function MemoryCard({ - entry, - entries, - actions, - disabled, - source, - now, - cueConfirm = false, - onEdit, - onEditorOpen, -}: { - entry: MemoryEntry; - /** The project's entries, for "Replaces" (§8.1). */ - entries: readonly MemoryEntry[]; - actions: MemoryActions; - disabled: boolean; - source?: ReactNode; - now: number; - /** Ring Confirm (the welcome tour's autoplay). */ - cueConfirm?: boolean; - /** Edit was pressed on a candidate (the welcome tour stops its autoplay). */ - onEdit?(): void; - /** Keep this row mounted while background results are reordered. */ - onEditorOpen?(): void; -}) { +const MemoryCard = memo(function MemoryCard({ entry, ops, busy, compact, source, nameOf }: { entry: GuardrailEntry; ops: Ops; busy: boolean; compact: boolean; source: Task | undefined; nameOf(status: Status): string }) { const { colors } = useTheme(); const openMenu = useMenuOpener(); const [editing, setEditing] = useState(false); @@ -174,138 +63,101 @@ export const MemoryCard = memo(function MemoryCard({ const [edited, setEdited] = useState(false); const [promoting, setPromoting] = useState(false); const [deleting, setDeleting] = useState(false); - const busy = actions.busy === entry.id; - const replaced = entry.supersedes ? entries.find((e) => e.id === entry.supersedes) : undefined; - const candidate = entry.status === "candidate"; + // Kept only while the page is open. + const [expanded, setExpanded] = useState(false); + const [hovered, setHovered] = useState(false); + const [focused, setFocused] = useState(false); + // Desktop shows the menu button on hover or keyboard focus; touch and compact layouts always show it. + const actionOpacity = useMotionValue(touch || compact || hovered || focused ? 1 : 0); + const closeEditor = () => { + setEdited(true); + setEditing(false); + }; if (editing) return ( - { - setEdited(true); - setEditing(false); - }} - onSubmit={async (draft) => { - if (await (candidate ? actions.confirm(entry, draft) : actions.save(entry, draft))) { - setEdited(true); - setEditing(false); - } - }} - /> + (await ops.save(entry, d)) && closeEditor()} /> ); - const widely = isWidelyUsed(entry, now); - const stale = isStale(entry, now); - const cards = recentCards(entry, now); - const used = entry.usedAt - ? tn(cards, "Used {when} · {n} card", "Used {when} · {n} cards", { when: relative(entry.usedAt, now) }) - : entry.status === "active" - ? t("Not used yet") - : ""; - const menu = (): Parameters>[2] => [ - { id: "edit", label: t("Edit"), icon: "Pencil", onPress: () => { onEditorOpen?.(); setEditing(true); } }, - entry.pinned - ? { id: "unpin", label: t("Unpin"), icon: "PinOff", onPress: () => void actions.pin(entry, false) } - : { id: "pin", label: t("Pin: every new session sees it"), icon: "Pin", onPress: () => void actions.pin(entry, true) }, - { id: "agents", label: t("Copy as AGENTS.md rule"), icon: "Copy", onPress: () => setPromoting(true) }, + const archived = entry.state === "archived"; + const kind = entry.noteKind ?? "decision"; + const color = kindColor(colors, kind); + const items = (): MenuEntry[] => [ + ...(archived + ? [{ id: "restore", label: t("Restore"), icon: "ArchiveRestore", onPress: () => void ops.setState(entry, "active") }] + : [ + { id: "edit", label: t("Edit"), icon: "Pencil", onPress: () => { ops.keep(entry.id); setEditing(true); } }, + entry.pinned + ? { id: "unpin", label: t("Unpin"), icon: "PinOff", onPress: () => void ops.pin(entry, false) } + : { id: "pin", label: t("Pin: every new session sees it"), icon: "Pin", onPress: () => void ops.pin(entry, true) }, + { id: "agents", label: t("Copy as AGENTS.md rule"), icon: "Copy", onPress: () => setPromoting(true) }, + { id: "archive", label: t("Archive"), icon: "Archive", onPress: () => void ops.setState(entry, "archived") }, + ]), { kind: "separator", id: "s" }, - { id: "archive", label: t("Archive"), icon: "Archive", onPress: () => void actions.archive(entry) }, { id: "delete", label: t("Delete…"), icon: "Trash2", danger: true, onPress: () => setDeleting(true) }, ]; - const footer = candidate ? ( - - - - - - - - - ) : entry.status === "active" ? ( - - {widely && !entry.pinned && ( - - )} - {openMenu ? ( - openMenu(eventPoint(event), entry.title, menu())} /> - ) : ( - <> - - - - - )} - - ) : ( - - - - - ); + const sourceId = entry.source?.taskId ?? entry.sources[0]?.taskId; + const reason = entry.state !== "active" ? reasonText(entry.stateReason) : ""; + const cards = new Set(entry.counts.usedBy.map((u) => u.taskId)).size; + const facts = [entry.tags?.length ? entry.tags.map((tag) => `#${tag}`).join(" ") : "", entry.counts.usedAt ? tn(cards, "Used by {n} card", "Used by {n} cards") : ""].filter(Boolean).join(" · "); const content = ( - - - - } text={t(kindLabels[entry.kind])} /> - {entry.pinned && } text={t("Pinned")} />} - {candidate && } - {entry.status === "archived" && } - {entry.status === "dismissed" && } - {stale && } text={t("Stale")} />} - {widely && } text={tn(cards, "Used by {n} card", "Used by {n} cards")} />} - {!!used && !widely && } - - {entry.title} - {!!entry.body && {entry.body}} - {!!entry.tags.length && } - {replaced && ( - - - {!!replaced.body && } - - )} - {!!entry.evidence && ( - - - - )} - {entry.pinned && entry.status === "active" && ( - + setExpanded(!expanded) : undefined} + onHoverIn={() => setHovered(true)} + onHoverOut={() => setHovered(false)} + {...menuHandlers(busy ? null : openMenu, entry.text, items)} + style={[cardFrameStyle(colors), noFocusRing, { borderLeftWidth: 2, borderLeftColor: color, padding: space[4], gap: space[2] }]} + > + + + + {!!reason && } + + {entry.pinned && } + {openMenu && ( + + setFocused(true)} + onBlur={() => setFocused(false)} + onPress={(event) => openMenu(eventPoint(event), entry.text, items())} + hitSlop={touch ? 8 : 4} + style={(state) => { + const { pressed, hovered: over } = state as typeof state & { hovered?: boolean }; + return { ...noFocusRing, width: 24, height: 24, alignItems: "center", justifyContent: "center", borderRadius: radius.md, backgroundColor: pressed || over || focused ? colors.surface2 : "transparent", opacity: busy ? 0.4 : 1 }; + }} + > + + + )} - - + + + {!!entry.body && } + {!!sourceId && ( + + + + )} + {!!facts && } + ); return ( <> {edited ? {content} : content} - {promoting && setPromoting(false)} />} + {promoting && setPromoting(false)} />} {deleting && ( setDeleting(false)} onConfirm={async () => { - await actions.remove(entry); + await ops.remove(entry); setDeleting(false); }} /> @@ -314,8 +166,18 @@ export const MemoryCard = memo(function MemoryCard({ ); }); -/** §5.4: copy the rule, paste it into AGENTS.md, then stop injecting it. */ -function PromoteDialog({ entry, actions, onClose }: { entry: MemoryEntry; actions: MemoryActions; onClose(): void }) { +/** The card body: 12 pt at 1.5 line height, clamped to `lines` while folded. */ +function BodyText({ text, lines }: { text: string; lines?: number }) { + const { colors } = useTheme(); + return ( + + {text} + + ); +} + +/** Copy the memory as an AGENTS.md rule, paste it there, then stop injecting it (archived as promoted). */ +function PromoteDialog({ entry, ops, onClose }: { entry: GuardrailEntry; ops: Ops; onClose(): void }) { const theme = useTheme(); return ( !open && onClose()}> @@ -324,16 +186,16 @@ function PromoteDialog({ entry, actions, onClose }: { entry: MemoryEntry; action - + - + - - - - - - - ); -} - -/** Make room (§6.5): the project is full; archive one of the longest-unused to continue. */ -function MakeRoomDialog({ projectId, entries, actions }: { projectId: string; entries: readonly MemoryEntry[]; actions: MemoryActions }) { - const theme = useTheme(); - const options = leastUsed(entries, projectId); - return ( - !open && actions.closeRoom()}> - - - - - {options.map((e) => ( - - - - - - - - ))} - - - - - - - - ); -} - -/** The inline form for a new memory, an edit, or "Save and confirm" of a candidate. */ -export function MemoryEditor({ - initial, - title, - submit, - allowPin, - onCancel, - onSubmit, -}: { - initial: Draft; - title: string; - submit: string; - allowPin: boolean; - onCancel(): void; - onSubmit(draft: Draft): Promise | void; -}) { - const { colors } = useTheme(); +function NoteEditor({ title, submit, initial, adding = false, onCancel, onSubmit }: { title: string; submit: string; initial: NoteDraft; adding?: boolean; onCancel(): void; onSubmit(d: NoteDraft): Promise }) { const [draft, setDraft] = useState(initial); - const [pending, setPending] = useState(false); - const set = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); - const valid = !!draft.title.trim(); - const go = async () => { - if (!valid || pending) return; - setPending(true); - try { - await onSubmit(draft); - } finally { - setPending(false); - } - }; + const set = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); return ( - - - ({ value: k, label: t(kindLabels[k]) }))} onChange={(kind) => set({ kind })} /> + onSubmit(draft)}> + {/* The kind is set when the memory is added; guardrails.update does not change it. */} + {adding && ({ value: k, label: t(kindLabels[k]) }))} onChange={(kind) => set({ kind })} />} set({ title: value })} placeholder={t("The rule or fact itself")} autoFocus /> set({ body: value })} multiline placeholder={t("What and why, or the symptom and how to avoid it. Never a secret.")} /> set({ tags: value })} placeholder={t("Comma-separated, e.g. drawer, mobile")} /> set({ keywords: value })} placeholder={t("Comma-separated")} /> - {allowPin && set({ pinned })} />} - - - - - + set({ pinned })} /> + ); } -type Filter = "all" | MemoryKind | "stale"; - -/** The project Memory page (§8.1). Replaces the board area while open, like Ideas and Insights. */ +/** The project's Memory page. Replaces the board area while open, like Ideas and Guardrails. */ export const MemoryPage = memo(function MemoryPage({ project, tasks, compact, + prefs, nameOf, + onPrefs, onOpenTask, onBack, }: { @@ -467,53 +239,49 @@ export const MemoryPage = memo(function MemoryPage({ /** The project's cards, archived included. */ tasks: readonly Task[]; compact: boolean; + /** Kind filter, sort and layout, remembered per host (board-view settings). */ + prefs: MemoryPrefs; nameOf(status: Status): string; + onPrefs(patch: Partial): void; onOpenTask(id: string): void; onBack(): void; }) { usePerfRender("project.memory.render"); const { colors } = useTheme(); - const query = useMemoryQuery(project.id, true); + const openMenu = useMenuOpener(); + const query = useGuardrailsQuery(project.id, true); const view = query.data; - const actions = useMemoryActions(project.id, view); - const [filter, setFilter] = useState("all"); - const [search, setSearch] = useState(""); + const actions = useGuardrailActions(project.id); const [adding, setAdding] = useState(false); - // Like the Ideas page: Archived and Dismissed are segments of their own, not a folded section. - const [tab, setTab] = useState<"memory" | "archived" | "dismissed">("memory"); - const [staleAsk, setStaleAsk] = useState(false); - const now = Date.now(); - const entries = view?.entries ?? noEntries; - const cards = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]); + const [archivedOpen, setArchivedOpen] = useState(false); + const [width, setWidth] = useState(0); const disabled = !!project.archivedAt || !view?.enabled; - const candidates = entries.filter((e) => e.status === "candidate").sort((a, b) => b.createdAt.localeCompare(a.createdAt)); - const active = entries.filter((e) => e.status === "active"); - const closedOf = (status: "dismissed" | "archived") => entries.filter((e) => e.status === status).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - const archived = closedOf("archived"); - const dismissed = closedOf("dismissed"); - const stale = active.filter((e) => isStale(e, now)); - const needle = search.trim().toLowerCase(); - const shown = active - .filter((e) => (filter === "all" ? true : filter === "stale" ? isStale(e, now) : e.kind === filter)) - .filter((e) => !needle || `${e.title}\n${e.body}\n${e.tags.join(" ")}\n${e.keywords.join(" ")}`.toLowerCase().includes(needle)) - .sort((a, b) => Number(b.pinned) - Number(a.pinned) || (b.confirmedAt || b.createdAt).localeCompare(a.confirmedAt || a.createdAt)); - const pending = candidates.filter((entry) => !actions.hidden.has(entry.id)); - const closed = tab === "archived" ? archived : dismissed; - const pageRows = usePageRows(JSON.stringify([project.id, tab, filter, needle]), tab === "memory" ? pending.length + shown.length : closed.length, compact); + const grid = !compact && prefs.memoryLayout === "grid"; + const cards = useMemo(() => new Map(tasks.map((x) => [x.id, x])), [tasks]); + const entries = view?.entries ?? noEntries; + const { live, archived } = useMemo(() => { + const notes = entries.filter((e) => e.kind === "note" && (prefs.memoryKind === "all" || e.noteKind === prefs.memoryKind)); + return { live: sortNotes(notes.filter((e) => e.state !== "archived"), prefs.memorySort), archived: sortNotes(notes.filter((e) => e.state === "archived"), "newest") }; + }, [entries, prefs.memoryKind, prefs.memorySort]); + const anyNotes = entries.some((e) => e.kind === "note"); + const listed = [...live, ...(archivedOpen ? archived : [])]; + const pageRows = usePageRows(JSON.stringify([project.id, prefs.memoryKind, prefs.memorySort, archivedOpen]), listed.length, compact); const [keptRows, setKeptRows] = useState>(() => new Set()); - const keepRow = (id: string) => setKeptRows((previous) => previous.has(id) ? previous : new Set([...previous, id])); - const pendingWindow = keepPageRows(pending, pageRows.limit, keptRows); - const activeWindow = keepPageRows(shown, Math.max(0, pageRows.limit - pending.length), keptRows); - const closedWindow = keepPageRows(closed, pageRows.limit, keptRows); - const remaining = tab === "memory" ? pending.length + shown.length - pendingWindow.length - activeWindow.length : closed.length - closedWindow.length; - const source = (e: MemoryEntry) => (e.source.taskId ? : undefined); - const card = (e: MemoryEntry, leaving: boolean) => ( - keepRow(e.id)} /> - ); - // The title outlives the last card's exit, so keep its count until the card has gone. - const reviewCount = useRef(0); - if (candidates.length) reviewCount.current = candidates.length; - const counts = (k: MemoryKind) => active.filter((e) => e.kind === k).length; + const windowed = new Set(keepPageRows(listed, pageRows.limit, keptRows).map((e) => e.id)); + const inWindow = (rows: readonly GuardrailEntry[]) => rows.filter((e) => windowed.has(e.id)); + const remaining = listed.length - windowed.size; + const keep = useStableCallback((id: string) => setKeptRows((previous) => (previous.has(id) ? previous : new Set([...previous, id])))); + const save = useStableCallback((e: GuardrailEntry, d: NoteDraft) => actions.saveNote(e, d)); + const pin = useStableCallback((e: GuardrailEntry, pinned: boolean) => actions.pin(e, pinned)); + const setState = useStableCallback((e: GuardrailEntry, state: GuardrailState, reason?: string) => actions.setState(e, state, reason)); + const remove = useStableCallback((e: GuardrailEntry) => actions.remove(e)); + const copyRule = useStableCallback((e: GuardrailEntry) => actions.copyRule(e)); + const openTask = useStableCallback((id: string) => onOpenTask(id)); + const ops = useMemo(() => ({ save, pin, setState, remove, copyRule, openTask, keep }), []); + const card = (e: GuardrailEntry, leaving: boolean) => { + const sourceId = e.source?.taskId ?? e.sources[0]?.taskId; + return ; + }; return ( @@ -527,223 +295,134 @@ export const MemoryPage = memo(function MemoryPage({ - + setWidth(Math.round(e.nativeEvent.layout.width))} style={{ width: "100%", maxWidth: grid ? 1120 : 760, gap: space[3] }}> {query.error && {message(query.error)}} - {view && !view.enabled && {t("Project memory is off for this project. Turn it on in the project's settings; what is here is kept.")}} - {active.length >= memoryLimits.warnAt && ( - {t("{n} of {max} memories. Archive stale ones to keep room.", { n: active.length, max: memoryLimits.activePerProject })} - )} - - {tab === "memory" ? ( - <> - } /> - - - - - {!disabled && !adding && ( - - )} - - - setAdding(false)} - onSubmit={async (draft) => { - if (await actions.create(draft)) setAdding(false); - }} - /> - - - setFilter("all")} /> - {memoryKinds.map((k) => ( - setFilter(filter === k ? "all" : k)} /> + {view && !view.enabled && {t("Guardrails are off for this project. Turn them on in the project's settings; what is here is kept.")}} + + + onPrefs({ memoryKind: "all" })} /> + {kindOrder.map((k) => ( + onPrefs({ memoryKind: prefs.memoryKind === k ? "all" : k })} /> ))} - {!!stale.length && setFilter(filter === "stale" ? "all" : "stale")} />} - {active.length > 3 && ( - { - if (event.nativeEvent.key === "Escape") onBack(); - }} - placeholder={t("Search memory")} - accessibilityLabel={t("Search memory")} - style={{ height: touch ? 40 : 32, paddingHorizontal: space[3], borderRadius: radius.md, borderWidth: 1, borderColor: colors.border, color: colors.foreground, fontSize: font.base }} - /> - )} - {filter === "stale" && !!stale.length && !disabled && ( - - - - + + {!compact && ( + + onPrefs({ memoryLayout: "list" })} /> + onPrefs({ memoryLayout: "grid" })} /> )} - - - {/* Typing in the search box starts a fresh list instead of collapsing every row that stops matching. */} - - - - - } - /> - - - - ) : ( - - - - - - - } - /> - - + {!disabled && !adding && ( + + + )} + + setAdding(false)} + onSubmit={async (d) => (await actions.addNote(d)) && setAdding(false)} + /> + + + + {!live.length ? ( + + + + + ) : grid && width ? ( + + ) : ( + + )} + {!!archived.length && ( + + setArchivedOpen(!archivedOpen)} + style={{ ...noFocusRing, flexDirection: "row", alignItems: "center", gap: space[1], minHeight: touch ? 36 : 24 }} + > + + + + + {grid && width ? : } + + + )} + + {remaining > 0 && } - {!!view && view.recall.queries > 0 && ( - - )} - {actions.room && } - {staleAsk && ( - setStaleAsk(false)} - onConfirm={async () => { - await actions.archiveStale(stale); - setStaleAsk(false); - setFilter("all"); - }} - /> - )} ); }); -const noEntries: MemoryEntry[] = []; +const noEntries: GuardrailEntry[] = []; -/** - * A list of memory cards that behaves like the Ideas list: a card that leaves - * it (confirmed, dismissed, archived, restored, deleted) collapses away in - * place, and one that arrives rises in. Cards present on first render just sit - * there. `before` (a title) shows while any row is still on screen. - */ -function MemoryRows({ entries, actions, render, before, empty }: { entries: readonly MemoryEntry[]; actions: MemoryActions; render(entry: MemoryEntry, leaving: boolean): ReactNode; before?: ReactNode; empty?: ReactNode }) { - const { hidden } = actions; - const shown = useMemo(() => (hidden.size ? entries.filter((e) => !hidden.has(e.id)) : entries), [entries, hidden]); - const rows = useExitList(shown); - if (!rows.length) return <>{empty}; - const body = rows.map((row) => ( - - {render(row.item, row.leaving)} - - )); - return before ? {before}{body} : <>{body}; +/** A list of memory cards like the Ideas list: a card that leaves collapses away in place, one that arrives rises in. */ +function MemoryRows({ entries, spacing, render }: { entries: readonly GuardrailEntry[]; spacing?: number; render(entry: GuardrailEntry, leaving: boolean): ReactNode }) { + const rows = useExitList(entries); + return ( + + {rows.map((row) => ( + + {render(row.item, row.leaving)} + + ))} + + ); } /** - * The drawer's Memory section (§8.2): candidates from this card with the page's - * actions, how many confirmed ones came from it, and Find memories on finished cards. + * Keep-style masonry: columns by width, each card into the shortest column by its estimated height. + * Cards keep their column across refreshes; a new filter or sort (`layoutKey`) or column count lays them out again. */ -export function TaskMemorySection({ task, project, onOpenPage }: { task: Task; project: Project; onOpenPage(): void }) { - const { colors } = useTheme(); - const [open, setOpen] = useState(true); - const finished = task.status === "done" || task.status === "cancelled"; - const query = useMemoryQuery(project.id, true); - const view = query.data; - const actions = useMemoryActions(project.id, view); - const own = view?.entries.filter((e) => e.source.taskId === task.id) ?? []; - const candidates = own.filter((e) => e.status === "candidate"); - const kept = own.filter((e) => e.status === "active").length; - // The section outlives its last candidate's exit. - const present = useHeld(view?.enabled && (candidates.length > 0 || kept > 0 || finished) ? 1 : 0); - if (!view || !view.enabled || !present) return null; - const running = view.running.includes(task.id); - const error = view.errors.find((e) => e.taskId === task.id); - const now = Date.now(); - return ( -
setOpen(!open)} - actions={ - finished ? ( - - ) : undefined - } - > - } /> - {!!kept && ( - - - - - )} - {finished && !candidates.length && !kept && !running && !error && ( - - )} - {error && } - {actions.room && } -
- ); -} +const gutter = space[4]; -function SectionTitle({ icon, title, ai }: { icon: string; title: string; ai?: boolean }) { - const { colors } = useTheme(); +function MemoryGrid({ entries, width, layoutKey, render }: { entries: readonly GuardrailEntry[]; width: number; layoutKey: string; render(entry: GuardrailEntry, leaving: boolean): ReactNode }) { + const columns = gridColumns(width); + const columnWidth = (width - gutter * (columns - 1)) / columns; + const placed = useRef<{ key: string; at: Map }>({ key: "", at: new Map() }); + const key = `${layoutKey}|${columns}`; + const previous = placed.current.key === key ? placed.current.at : undefined; + const laid = placeMasonry(entries, columns, (e) => estimateHeight(e, columnWidth), previous); + placed.current = { key, at: new Map(laid.flatMap((column, index) => column.map((e) => [e.id, index] as const))) }; return ( - - - - {ai && } + + {laid.map((column, index) => ( + + + + ))} ); } -function FilterChip({ on, icon, label, onPress }: { on: boolean; icon?: string; label: string; onPress(): void }) { +function FilterChip({ on, icon, iconColor, label, onPress }: { on: boolean; icon?: string; iconColor?: string; label: string; onPress(): void }) { const { colors } = useTheme(); return ( - {!!icon && } + {!!icon && } ); } - -/** "3d ago" style, for the usage line. */ -function relative(iso: string, now: number) { - const minutes = Math.max(0, Math.round((now - Date.parse(iso)) / 60000)); - if (minutes < 60) return t("{n}m ago", { n: Math.max(1, minutes) }); - const hours = Math.round(minutes / 60); - if (hours < 24) return t("{n}h ago", { n: hours }); - return t("{n}d ago", { n: Math.round(hours / 24) }); -} diff --git a/client/onboarding-logic.test.ts b/client/onboarding-logic.test.ts index 26a7c872..c52adb04 100644 --- a/client/onboarding-logic.test.ts +++ b/client/onboarding-logic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { TIPS_SEEN_MAX, firstRunOutside, loopAutoStep, memoryAutoStep, nextTip, outsidePatch, outsideShown, settingsHintDue, stepPage, tipIds, tourKey, welcomeDecision, welcomePageCount, withTipSeen, type TipFacts } from "./onboarding-logic"; +import { TIPS_SEEN_MAX, firstRunOutside, loopAutoStep, nextTip, outsidePatch, outsideShown, settingsHintDue, stepPage, tipIds, tourKey, welcomeDecision, welcomePageCount, withTipSeen, type TipFacts } from "./onboarding-logic"; import { featureSettingsValues, nativeSessionSettingsValues, onboardingSettingsValues } from "../shared/settings"; const base = { settingsStatus: "ready", welcomeSeen: false, boardLoaded: true, projectCount: 0, blocked: false }; @@ -32,12 +32,12 @@ test("tour paging stays inside the pages", () => { }); describe("tips", () => { - const none: TipFacts = { hasCard: false, needsYou: false, openIdea: false, movedToFinished: false, hasArtifact: false, hasAgentTag: false, agentQuestion: false, olderHidden: false, nextMove: false, hasTicket: false, noConnection: false, projectTabs: false, hasWorkpad: false, busyBoard: false, hasSubagent: false, workflowSuggestion: false, crowdedAttention: false, fullSidebar: false, cardOpen: false, cardWithSession: false, hasSummary: false, skillDraft: false, memoryCandidate: false, searchable: false }; + const none: TipFacts = { hasCard: false, needsYou: false, openIdea: false, movedToFinished: false, hasArtifact: false, hasAgentTag: false, agentQuestion: false, olderHidden: false, nextMove: false, hasTicket: false, noConnection: false, projectTabs: false, hasWorkpad: false, busyBoard: false, hasSubagent: false, workflowSuggestion: false, crowdedAttention: false, fullSidebar: false, cardOpen: false, cardWithSession: false, hasSummary: false, skillDraft: false, searchable: false, reviewShown: false, learnedGuideline: false }; test("nothing to show without a fact", () => { expect(nextTip({ tipsSeen: [], facts: none, blocked: false })).toBeNull(); }); test("one tip at a time, waiting-for-you first", () => { - const facts = { hasCard: true, needsYou: true, openIdea: true, movedToFinished: false, hasArtifact: true, hasAgentTag: true, agentQuestion: false, olderHidden: false, nextMove: false, hasTicket: false, noConnection: false, projectTabs: false, hasWorkpad: false, busyBoard: false, hasSubagent: false, workflowSuggestion: false, crowdedAttention: false, fullSidebar: false, cardOpen: false, cardWithSession: false, hasSummary: false, skillDraft: false, memoryCandidate: false, searchable: false }; + const facts = { hasCard: true, needsYou: true, openIdea: true, movedToFinished: false, hasArtifact: true, hasAgentTag: true, agentQuestion: false, olderHidden: false, nextMove: false, hasTicket: false, noConnection: false, projectTabs: false, hasWorkpad: false, busyBoard: false, hasSubagent: false, workflowSuggestion: false, crowdedAttention: false, fullSidebar: false, cardOpen: false, cardWithSession: false, hasSummary: false, skillDraft: false, searchable: false, reviewShown: false, learnedGuideline: false }; expect(nextTip({ tipsSeen: [], facts, blocked: false })).toBe("needs-you"); expect(nextTip({ tipsSeen: ["needs-you"], facts, blocked: false })).toBe("first-idea"); expect(nextTip({ tipsSeen: ["needs-you", "first-idea"], facts, blocked: false })).toBe("first-artifact"); @@ -73,13 +73,6 @@ describe("tips", () => { expect(nextTip({ tipsSeen: ["next-move", "first-workpad"], facts, blocked: false })).toBe("first-idea"); expect(nextTip({ tipsSeen: ["first-workpad"], facts: { ...none, hasWorkpad: true }, blocked: false })).toBeNull(); }); - test("a project-memory candidate is announced after ideas, before the workflow-insights tip", () => { - const facts = { ...none, openIdea: true, memoryCandidate: true, workflowSuggestion: true }; - expect(nextTip({ tipsSeen: [], facts, blocked: false })).toBe("first-idea"); - expect(nextTip({ tipsSeen: ["first-idea"], facts, blocked: false })).toBe("first-memory-candidate"); - expect(nextTip({ tipsSeen: ["first-idea", "first-memory-candidate"], facts, blocked: false })).toBe("first-workflow-insight"); - expect(nextTip({ tipsSeen: ["first-memory-candidate"], facts: { ...none, memoryCandidate: true }, blocked: false })).toBeNull(); - }); test("search follows card display and comes before the connectors tip (SEARCH-SPEC §8)", () => { expect(nextTip({ tipsSeen: [], facts: { ...none, searchable: true }, blocked: false })).toBe("search"); expect(nextTip({ tipsSeen: [], facts: { ...none, searchable: true, busyBoard: true, cardOpen: true }, blocked: false })).toBe("card-display"); @@ -220,7 +213,7 @@ describe("tour setup (ONBOARDING-SETUP-SPEC)", () => { expect(settingsHintDue({ tipsSeen: ["settings-entry"], tourClosed: true, blocked: false })).toBe(false); }); test("the settings callout is never a banner tip", () => { - const facts: Array = ["hasCard", "needsYou", "openIdea", "movedToFinished", "hasArtifact", "hasAgentTag", "agentQuestion", "olderHidden", "nextMove", "hasTicket", "noConnection", "projectTabs", "hasWorkpad", "busyBoard", "hasSubagent", "workflowSuggestion", "crowdedAttention", "fullSidebar", "cardOpen", "cardWithSession", "hasSummary", "skillDraft", "memoryCandidate", "searchable"]; + const facts: Array = ["hasCard", "needsYou", "openIdea", "movedToFinished", "hasArtifact", "hasAgentTag", "agentQuestion", "olderHidden", "nextMove", "hasTicket", "noConnection", "projectTabs", "hasWorkpad", "busyBoard", "hasSubagent", "workflowSuggestion", "crowdedAttention", "fullSidebar", "cardOpen", "cardWithSession", "hasSummary", "skillDraft", "searchable", "reviewShown", "learnedGuideline"]; const all = Object.fromEntries(facts.map((k) => [k, true])) as unknown as TipFacts; const seen: string[] = []; for (let tip = nextTip({ tipsSeen: seen, facts: all, blocked: false }); tip; tip = nextTip({ tipsSeen: seen, facts: all, blocked: false })) seen.push(tip); @@ -271,12 +264,28 @@ describe("Built in demo autoplay (ONBOARDING-SETUP-SPEC §3)", () => { }); }); -describe("Project memory demo autoplay (ONBOARDING-SETUP-SPEC §3)", () => { - test("confirms the suggestion, waits for the new card's session, then starts over", () => { - expect(memoryAutoStep("suggested")?.cue).toBe("confirm"); - expect(memoryAutoStep("confirmed")).toBeNull(); - expect(memoryAutoStep("working")).toBeNull(); - expect(memoryAutoStep("used")?.cue).toBe("restart"); - expect(memoryAutoStep("dismissed")?.cue).toBe("restart"); - }); +test("auto-review tip: shown once when a card shows a review state, after next moves and before the workpad tip", () => { + const none: TipFacts = { hasCard: false, needsYou: false, openIdea: false, movedToFinished: false, hasArtifact: false, hasAgentTag: false, agentQuestion: false, olderHidden: false, nextMove: false, hasTicket: false, noConnection: false, projectTabs: false, hasWorkpad: false, busyBoard: false, hasSubagent: false, workflowSuggestion: false, crowdedAttention: false, fullSidebar: false, cardOpen: false, cardWithSession: false, hasSummary: false, skillDraft: false, searchable: false, reviewShown: false, learnedGuideline: false }; + const facts = { ...none, reviewShown: true, hasWorkpad: true, nextMove: true }; + expect(nextTip({ tipsSeen: [], facts, blocked: false })).toBe("next-move"); + expect(nextTip({ tipsSeen: ["next-move"], facts, blocked: false })).toBe("auto-review"); + expect(nextTip({ tipsSeen: ["next-move", "auto-review"], facts, blocked: false })).toBe("first-workpad"); + expect(nextTip({ tipsSeen: [], facts: none, blocked: false })).toBeNull(); +}); + + +test("first-guardrail tip: shown once when the project learned a guideline, right after the auto-review tip and before the workpad tip", () => { + const none: TipFacts = { hasCard: false, needsYou: false, openIdea: false, movedToFinished: false, hasArtifact: false, hasAgentTag: false, agentQuestion: false, olderHidden: false, nextMove: false, hasTicket: false, noConnection: false, projectTabs: false, hasWorkpad: false, busyBoard: false, hasSubagent: false, workflowSuggestion: false, crowdedAttention: false, fullSidebar: false, cardOpen: false, cardWithSession: false, hasSummary: false, skillDraft: false, searchable: false, reviewShown: false, learnedGuideline: false }; + expect(nextTip({ tipsSeen: [], facts: none, blocked: false })).toBeNull(); + const facts = { ...none, reviewShown: true, learnedGuideline: true, hasWorkpad: true }; + expect(nextTip({ tipsSeen: [], facts, blocked: false })).toBe("auto-review"); + expect(nextTip({ tipsSeen: ["auto-review"], facts, blocked: false })).toBe("first-guardrail"); + expect(nextTip({ tipsSeen: ["auto-review", "first-guardrail"], facts, blocked: false })).toBe("first-workpad"); + expect(nextTip({ tipsSeen: ["first-guardrail"], facts: { ...none, learnedGuideline: true }, blocked: false })).toBeNull(); + expect(nextTip({ tipsSeen: [], facts, blocked: true })).toBeNull(); +}); + +test("the retired memory-candidate and review-suggest tips are gone", () => { + expect(tipIds).not.toContain("first-memory-candidate"); + expect(tipIds).not.toContain("review-suggest"); }); diff --git a/client/onboarding-logic.ts b/client/onboarding-logic.ts index 54a7a851..b5ce2500 100644 --- a/client/onboarding-logic.ts +++ b/client/onboarding-logic.ts @@ -1,6 +1,6 @@ /** Pure decisions for first-run guidance (ONBOARDING-SPEC). No React, no storage. */ -/** How it works, Built in, Project memory, Insights, Ready (ONBOARDING-SETUP-SPEC §3). */ +/** How it works, Built in, Guardrails, Insights, Ready (ONBOARDING-SETUP-SPEC §3). */ export const welcomePageCount = 5; /** Page index after a Back/Next step, kept inside the tour. */ @@ -29,7 +29,7 @@ export function welcomeDecision(input: { return input.blocked ? "wait" : "show"; } -export const tipIds = ["first-card", "needs-you", "first-idea", "first-done", "first-artifact", "first-tag", "agent-question", "older-finished", "next-move", "first-ticket", "jira-connect", "project-menu", "first-workpad", "subagents", "card-display", "first-workflow-insight", "attention-dismiss", "sidebar-order", "connectors", "session-history", "ai-language", "summary-models", "first-skill-draft", "first-memory-candidate", "search", "settings-entry"] as const; +export const tipIds = ["first-card", "needs-you", "first-idea", "first-done", "first-artifact", "first-tag", "agent-question", "older-finished", "next-move", "first-ticket", "jira-connect", "project-menu", "first-workpad", "subagents", "card-display", "first-workflow-insight", "attention-dismiss", "sidebar-order", "connectors", "session-history", "ai-language", "summary-models", "first-skill-draft", "search", "auto-review", "first-guardrail", "settings-entry"] as const; export type TipId = (typeof tipIds)[number]; export interface TipFacts { @@ -78,10 +78,12 @@ export interface TipFacts { /** The current project has 3 or more finished cards and no card is open, so Insights has history to show (INSIGHTS-REDESIGN-SPEC §8). */ /** An open skill draft in the current project, Insights and Skills on (INSIGHTS-REDESIGN-SPEC §5). */ skillDraft: boolean; - /** The current project has a project-memory candidate waiting for review (PROJECT-MEMORY-SPEC §11). */ - memoryCandidate: boolean; /** The current project shows 10 or more cards and no card is open, so there is history to search (SEARCH-SPEC §8). */ searchable: boolean; + /** A card of the current project shows an automatic review state and no card is open (AUTO-REVIEW-SPEC §9.1). */ + reviewShown: boolean; + /** The current project learned a guideline from cards sent back, this week, and no card is open (GUARDRAILS-SPEC §10). */ + learnedGuideline: boolean; } /** Banner tips; `settings-entry` is the callout by the settings button instead (settingsHintDue). */ @@ -109,15 +111,16 @@ const tipFact: Record, keyof TipFacts> = { "ai-language": "hasSummary", "summary-models": "hasSummary", "first-skill-draft": "skillDraft", - "first-memory-candidate": "memoryCandidate", search: "searchable", + "auto-review": "reviewShown", + "first-guardrail": "learnedGuideline", }; /** The one tip to show now: first in priority order whose fact holds and that was not shown before. */ export function nextTip(input: { tipsSeen: readonly string[]; facts: TipFacts; blocked: boolean }): TipId | null { if (input.blocked) return null; // Waiting for the user outranks introductions; the answer-in-session tip is the more specific one. - const order: Exclude[] = ["agent-question", "needs-you", "attention-dismiss", "first-done", "older-finished", "next-move", "first-workpad", "subagents", "first-idea", "first-memory-candidate", "first-skill-draft", "first-workflow-insight", "ai-language", "summary-models", "first-artifact", "first-ticket", "first-tag", "first-card", "card-display", "search", "connectors", "session-history", "sidebar-order", "project-menu", "jira-connect"]; + const order: Exclude[] = ["agent-question", "needs-you", "attention-dismiss", "first-done", "older-finished", "next-move", "auto-review", "first-guardrail", "first-workpad", "subagents", "first-idea", "first-skill-draft", "first-workflow-insight", "ai-language", "summary-models", "first-artifact", "first-ticket", "first-tag", "first-card", "card-display", "search", "connectors", "session-history", "sidebar-order", "project-menu", "jira-connect"]; return order.find((id) => input.facts[tipFact[id]] && !input.tipsSeen.includes(id)) ?? null; } @@ -199,14 +202,3 @@ export function loopAutoStep(s: { if (s.turn === 0) return s.draft ? { cue: "send", wait: 800 } : { cue: "move", wait: 1500 }; return null; } - -/** The Project memory demo (page 3): suggested → confirmed → a new card's session uses it. */ -export type MemoryPhase = "suggested" | "confirmed" | "working" | "used" | "dismissed"; -export type MemoryAutoStep = { cue: "confirm" | "restart"; wait: number }; - -/** Autoplay presses Confirm, lets the new card's session run by itself, then starts over. */ -export function memoryAutoStep(phase: MemoryPhase): MemoryAutoStep | null { - if (phase === "suggested") return { cue: "confirm", wait: 1500 }; - if (phase === "used" || phase === "dismissed") return { cue: "restart", wait: 4000 }; - return null; -} diff --git a/client/onboarding.tsx b/client/onboarding.tsx index 82fa659e..80c00650 100644 --- a/client/onboarding.tsx +++ b/client/onboarding.tsx @@ -14,7 +14,7 @@ import { useStored } from "./stored-settings"; export { useOnboarding } from "./onboarding-state"; import { Button, IconButton, Label, Theme, font, noFocusRing, radius, space, tint, touch, useTheme } from "./ui"; import { useShortcutBlock } from "./shortcuts"; -import { AttentionDemo, DemoControls, DemoFrame, LoopDemo, MemoryDemo, SideTitle } from "./tour-demos"; +import { AttentionDemo, DemoControls, DemoFrame, LoopDemo, SideTitle } from "./tour-demos"; import { formatLocale, t, tn } from "../shared/i18n"; /** The three Kanban objects and the Paseo object each one is (page 1 and the empty-board strip). */ @@ -156,6 +156,7 @@ function BuiltInPage({ active, outside, onOutside }: { active: boolean; outside: , ]} + } /> - + - + {[ - , - , - , + , + , + , ]} ); } +/** The two-line example: your words on one card become a check on the next. */ +function GuardrailsExample() { + const { colors } = useTheme(); + const quote = `“${t("New text needs every language too.")}”`; + const line = (icon: string, color: string, title: string, text: string) => ( + + + + + + + + + + ); + return ( + + {line("RotateCcw", colors.foregroundMuted, t("You send card A back"), quote)} + {line("ShieldAlert", colors.statusDanger, t("Card B is sent back before it reaches you"), `✗ ${quote} ${t("zh-TW has no entry for “Rerun”.")}`)} + + ); +} + /** Weekday names for the sample's labels, Monday first, in the interface locale (2026-10-19 is a Monday). */ const sampleWeekday = (offset: number) => new Date(Date.UTC(2026, 9, 19 + offset, 12)).toLocaleDateString(formatLocale(), { weekday: "short", timeZone: "UTC" }); @@ -399,9 +423,11 @@ function Hint({ icon, text }: { icon: string; text: string }) { function MoreFeatures() { const { colors } = useTheme(); const more = [ - { icon: "ListChecks", title: t("Workpad"), text: t("Agents set the card's type and tags and write a workpad before they start: acceptance criteria, checks marked passed, failed or not run on a named commit, and what confused them. Delivering without them needs a note.") }, + { icon: "ListChecks", title: t("Workpad"), text: t("Agents set the card's type and tags and write an acceptance list before they start; the card shows how many items are met, next to the latest review.") }, + { icon: "ShieldCheck", title: t("Auto review"), text: t("Deliveries are checked before they reach you, and go back to the agent when they fall short.") }, { icon: "Package", title: t("Inputs and outputs"), text: t("Each card lists the files and links it worked from and delivered, added by agents or by you.") }, { icon: "Ticket", title: t("Tickets, docs and PRs as inputs"), text: t("Start a card from a Jira ticket, Confluence page, GitHub/GitLab issue or pull request, or Azure DevOps work item, or add one with the Artifacts +. Agents read it every turn; Flowly never changes it.") }, + { icon: "BookMarked", title: t("Memory"), text: t("Agents remember the project's decisions, pitfalls and conventions; new sessions see them. Open Memory to pin, edit or archive them.") }, { icon: "Search", title: t("Search"), text: t("Search past cards, summaries, notes and deliverables.") }, { icon: "Tags", title: t("Tags"), text: t("Tag cards by module or direction. Agents tag their own cards too; filter and search by #tag.") }, { icon: "FolderGit2", title: t("Saved with your project"), text: t("Each project's board is also kept in its folder (.flowly, ignored by git). Add the folder again to restore it.") }, @@ -695,7 +721,7 @@ function TourWindow({ ) : page === 1 ? ( ) : page === 2 ? ( - + ) : page === 3 ? ( ) : ( diff --git a/client/overview-bar.tsx b/client/overview-bar.tsx index a41cbdf3..3db9373d 100644 --- a/client/overview-bar.tsx +++ b/client/overview-bar.tsx @@ -22,7 +22,10 @@ export function OverviewBar({ openIdeas, onIdeas, ideasShortcut, - memoryCandidates, + guardrailCount, + onGuardrails, + guardrailsShortcut, + memoryCount, onMemory, memoryShortcut, }: { @@ -37,8 +40,12 @@ export function OverviewBar({ openIdeas: number | null; onIdeas(): void; ideasShortcut: string; - /** Project memory candidates waiting for review (PROJECT-MEMORY-SPEC §8.1); null when memory is off for the project. */ - memoryCandidates: number | null; + /** Observing commands plus guidelines learned this week (GUARDRAILS-SPEC §7.4); null when guardrails are off for the project. */ + guardrailCount: number | null; + onGuardrails(): void; + guardrailsShortcut: string; + /** Memory added this week (GUARDRAILS-SPEC §7.4); null when guardrails are off for the project. */ + memoryCount: number | null; onMemory(): void; memoryShortcut: string; /** Jump to a lane (compact: switch tabs; desktop: expand it if collapsed). */ @@ -110,13 +117,22 @@ export function OverviewBar({ {openIdeas ? t("Ideas {openIdeas}", { openIdeas }) : t("Ideas")} )} - {memoryCandidates !== null && ( + {guardrailCount !== null && ( } + label={`${guardrailCount ? t("Guardrails: {n} new or observing", { n: guardrailCount }) : t("Guardrails")}${guardrailsShortcut ? ` (${guardrailsShortcut})` : ""}`} + icon={} + onPress={onGuardrails} + > + {guardrailCount ? t("Guardrails {n}", { n: guardrailCount }) : t("Guardrails")} + + )} + {memoryCount !== null && ( + } onPress={onMemory} > - {memoryCandidates ? t("Memory {n}", { n: memoryCandidates }) : t("Memory")} + {memoryCount ? t("Memory {n}", { n: memoryCount }) : t("Memory")} )} {onInsights && ( diff --git a/client/queries.ts b/client/queries.ts index 5b9f3f1f..9efccc48 100644 --- a/client/queries.ts +++ b/client/queries.ts @@ -351,12 +351,12 @@ export function useWorkflowInsightsQuery(projectId: string, enabled: boolean, po }); } -/** A project's memory (PROJECT-MEMORY-SPEC §5.1): read while the Memory page or a drawer's Memory section is shown. */ -export const memoryKey = (projectId: string) => ["flowly", "memory", projectId] as const; -export function useMemoryQuery(projectId: string, enabled: boolean) { - const read = useRpc(rpc.readMemory); +/** A project's guardrails (GUARDRAILS-SPEC §7.4): read while the Guardrails page is shown. */ +export const guardrailsKey = (projectId: string) => ["flowly", "guardrails", projectId] as const; +export function useGuardrailsQuery(projectId: string, enabled: boolean) { + const read = useRpc(rpc.readGuardrails); const client = useQueryClient(); - const key = memoryKey(projectId); + const key = guardrailsKey(projectId); return useQuery({ queryKey: key, queryFn: () => readChanged(client, key, (etag) => read({ projectId, ...(etag ? { etag } : {}) })), @@ -367,14 +367,14 @@ export function useMemoryQuery(projectId: string, enabled: boolean) { placeholderData: (previous) => previous, }); } -/** Candidates waiting per project, for the overview chip; read with the board, once a minute. */ -export const memoryCountsKey = ["flowly", "memory-counts"] as const; -export function useMemoryCountsQuery(enabled: boolean) { - const read = useRpc(rpc.memoryCounts); +/** Observing commands and guidelines learned this week per project, for the overview chip; read with the board, once a minute. */ +export const guardrailCountsKey = ["flowly", "guardrail-counts"] as const; +export function useGuardrailCountsQuery(enabled: boolean) { + const read = useRpc(rpc.guardrailCounts); const client = useQueryClient(); return useQuery({ - queryKey: memoryCountsKey, - queryFn: () => readChanged(client, memoryCountsKey, (etag) => read(etag ? { etag } : {})), + queryKey: guardrailCountsKey, + queryFn: () => readChanged(client, guardrailCountsKey, (etag) => read(etag ? { etag } : {})), enabled, staleTime: 30_000, refetchInterval: enabled ? 60_000 : false, diff --git a/client/review-settings.tsx b/client/review-settings.tsx new file mode 100644 index 00000000..fd63185b --- /dev/null +++ b/client/review-settings.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef } from "react"; +import { View } from "react-native"; +import type { AutoReviewInput, Project } from "../shared/model"; +import { useGuardrailsQuery } from "./queries"; +import { Choices, Label, Toggle, font, space } from "./ui"; +import { t } from "../shared/i18n"; + +export type ReviewEdit = { on: boolean; rounds: string }; + +/** phase B: `projects.update` takes `autoReview: { enabled, maxRounds }` only; this branch's input still lists `rules`. */ +export const autoReviewPatch = (edit: ReviewEdit): AutoReviewInput => ({ enabled: edit.on, maxRounds: Number(edit.rounds) }); + +/** + * New project / Edit project → Auto review (GUARDRAILS-SPEC §8): the switch with its cost, one line of the project's + * check commands when it already has some, and Max rework rounds. The daemon detects the commands when the project is + * created or saved with the switch turned on (§4.5); nothing is detected or added before Save. + */ +export function AutoReviewSection({ + project, + value, + onChange, + switchOn, +}: { + project?: Project; + value: ReviewEdit; + onChange(next: ReviewEdit): void; + /** Opened from What's new: the switch starts on. */ + switchOn?: boolean; +}) { + // The project's active and observing commands, when it already has them. + const store = useGuardrailsQuery(project?.id ?? "", !!project && value.on).data; + const commands = (store?.entries ?? []).filter((e) => e.kind === "command" && (e.state === "active" || e.state === "observing")).map((e) => e.text); + const set = (patch: Partial) => onChange({ ...value, ...patch }); + const asked = useRef(false); + useEffect(() => { + if (!switchOn || asked.current) return; + asked.current = true; + if (!value.on) set({ on: true }); + // Once per opening. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [switchOn]); + return ( + + set({ on })} /> + {value.on && !!commands.length && ( + + )} + {value.on && ( + ({ value: v, label: v }))} + onChange={(rounds) => set({ rounds })} + /> + )} + + ); +} diff --git a/client/search-page.tsx b/client/search-page.tsx index 54e133f1..d65aec24 100644 --- a/client/search-page.tsx +++ b/client/search-page.tsx @@ -15,7 +15,7 @@ import { useStableCallback } from "./stable"; import { Button, Choices, Label, Notice, TextBox, font, message, noFocusRing, pageBarHeight, radius, space, tint, touch, useTheme, verticalContent, verticalScroll } from "./ui"; import { t, msg } from "../shared/i18n"; -/** The Search page (SEARCH-SPEC §5). Replaces the board area while open, like Ideas and Memory. */ +/** The Search page (SEARCH-SPEC §5). Replaces the board area while open, like Ideas and Guardrails. */ const sourceChips: Record = { card: { label: msg("Cards"), icon: "SquareKanban" }, diff --git a/client/settings-pages.tsx b/client/settings-pages.tsx index 44868dd1..85966caf 100644 --- a/client/settings-pages.tsx +++ b/client/settings-pages.tsx @@ -199,10 +199,60 @@ export function ModelsPage({ active }: { active: boolean }) { onChange={(next: AgentModel | null) => models.save({ defaultProvider: next?.provider ?? "", defaultModel: next?.model ?? "", defaultThinking: next?.thinking ?? "", defaultMode: next?.mode ?? "" })} /> + ); } +/** The auto review session's model (AUTO-REVIEW-SPEC §12.4); Default: the project's default model, then the summary models. */ +function ReviewModelGroup({ active }: { active: boolean }) { + const paseo = usePaseo(); + const summary = useStored(useSettings(summarySettings), summarySettingsValues); + const stored = summary.values?.reviewModel || AUTO; + const storedProvider = stored ? stored.slice(0, stored.indexOf("/")) : AUTO; + const [provider, setProvider] = useState(storedProvider); + useEffect(() => setProvider(storedProvider), [storedProvider]); + const providers = useQuery({ queryKey: ["flowly", "providers"], queryFn: () => paseo.providers.listAvailable(), enabled: active }); + const providerInfo = useQuery({ queryKey: ["flowly", "providerSnapshot"], queryFn: () => paseo.providers.snapshot(), enabled: active, staleTime: 60000 }); + const models = useQuery({ + queryKey: ["flowly", "review-models", provider], + queryFn: async () => { + const result = await paseo.providers.listModels(provider); + if (result.error) throw new Error(result.error); + return result; + }, + enabled: active && provider !== AUTO, + }); + const defaultLabel = t("Project default model"); + return ( + + { + setProvider(next); + if (next === AUTO) summary.save({ reviewModel: "" }); + }} + /> + {provider !== AUTO && ( + summary.save({ reviewModel: `${provider}/${model}` })} + /> + )} + + ); +} + /** Ordered summary fallback models, shared by Settings and the welcome tour. */ export function SummaryModelGroup({ active, bare = false, extra }: { active: boolean; bare?: boolean; extra?: ReactNode }) { const paseo = usePaseo(); @@ -415,23 +465,24 @@ export function AiPage() { ); } -/** Project memory as a whole and its finished-card review (Settings → AI features and the welcome tour). */ +/** Guardrails as a whole (the `memory` feature key) and the finished-card review (Settings → AI features and the welcome tour). */ export function MemoryFeatureGroup({ bare = false }: { bare?: boolean }) { const summary = useStored(useSettings(summarySettings), summarySettingsValues); const features = useStored(useSettings(featureSettings), featureSettingsValues); const on = features.values?.memory ?? true; return ( features.save({ memory })} /> { expect(resolveShortcut({ key: "N" }, true)).toBe("new-task"); expect(resolveShortcut({ key: "b" }, true)).toBe("ideas"); expect(resolveShortcut({ key: "i" }, true)).toBe("insights"); + expect(resolveShortcut({ key: "g" }, true)).toBe("guardrails"); + expect(resolveShortcut({ key: "m" }, true)).toBe("memory"); expect(resolveShortcut({ key: "p" }, true)).toBe("projects"); expect(resolveShortcut({ key: "/" }, true)).toBe("filters"); expect(resolveShortcut({ key: "f" }, true)).toBe("filters"); diff --git a/client/shortcuts.ts b/client/shortcuts.ts index c255dba5..9982a5fc 100644 --- a/client/shortcuts.ts +++ b/client/shortcuts.ts @@ -21,6 +21,7 @@ export type ShortcutAction = | "edit" | "insights" | "ideas" + | "guardrails" | "memory" | "search"; @@ -37,13 +38,14 @@ export const shortcutHelp: readonly { keys: string[]; title: string }[] = [ { keys: ["A"], title: msg("Needs attention") }, { keys: ["I"], title: msg("Project insights") }, { keys: ["B"], title: msg("Project ideas") }, - { keys: ["M"], title: msg("Project memory") }, + { keys: ["G"], title: msg("Guardrails") }, + { keys: ["M"], title: msg("Memory") }, { keys: ["J", "K"], title: msg("Next / previous card in column") }, { keys: ["H", "L"], title: msg("Card in left / right column") }, { keys: ["⇧H", "⇧L"], title: msg("Move the open card to the left / right column") }, { keys: ["E"], title: msg("Edit the open card") }, { keys: ["←", "→"], title: msg("Previous / next agent question") }, - { keys: ["Esc"], title: msg("Back from session, then close sidebar, then leave Insights, Ideas or Memory") }, + { keys: ["Esc"], title: msg("Back from session, then close sidebar, then leave a project page") }, { keys: ["?"], title: msg("Show keyboard shortcuts") }, ]; @@ -59,6 +61,7 @@ const letters: Record = { e: "edit", i: "insights", b: "ideas", + g: "guardrails", m: "memory", s: "search", }; diff --git a/client/task-review.tsx b/client/task-review.tsx new file mode 100644 index 00000000..e96ffcfa --- /dev/null +++ b/client/task-review.tsx @@ -0,0 +1,204 @@ +import { memo, useState } from "react"; +import { Pressable, View } from "react-native"; +import { Icon, useToast } from "@getpaseo/plugin/client/react-native"; +import { useRpc } from "@getpaseo/plugin/client"; +import { useQueryClient } from "@tanstack/react-query"; +import * as rpc from "../shared/rpc"; +import type { Project, Review, ReviewItem, ReviewItemState, Task } from "../shared/model"; +import { autoReviewRuns, outputTail } from "../shared/auto-review"; +import { invalidateCardViews } from "./card-detail-query"; +import { IconButton, Label, font, message, noFocusRing, space, useTheme } from "./ui"; +import { t } from "../shared/i18n"; + +/** The review state on the card face and in the drawer header (GUARDRAILS-SPEC §7.1). */ +export function reviewLabel(review: Review): string { + switch (review.state) { + case "running": + return t("Reviewing"); + case "passed": + return t("Checked"); + case "rework": + return t("Sent back"); + case "failed": + return t("Review failed"); + case "unavailable": + return t("Review unavailable"); + default: + return t("Review cancelled"); + } +} + +export function reviewIcon(review: Review): string { + return review.state === "running" ? "Loader" : review.state === "passed" ? "ShieldCheck" : review.state === "rework" ? "RotateCcw" : review.state === "failed" ? "ShieldAlert" : "CircleHelp"; +} + +/** phase B: `Review.tokens` (§6.4) is not on this branch's model yet. */ + +const seconds = (ms: number) => t("{n} s", { n: Math.max(1, Math.round(ms / 1000)) }); +const compactCount = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`); + +/** Failed first, then disputed, then what is still going, then passed or not applicable (§7.1). */ +const itemRank: Record = { failed: 0, disputed: 1, running: 2, queued: 3, unavailable: 4, passed: 5, na: 5, skipped: 6 }; +export const sortedItems = (items: readonly ReviewItem[]) => items.map((item, index) => ({ item, index })).sort((a, b) => itemRank[a.item.state] - itemRank[b.item.state] || a.index - b.index).map((x) => x.item); + +function resultText(r: ReviewItem): string { + switch (r.state) { + case "queued": + case "running": + return ""; + case "na": + return t("Not applicable"); + case "skipped": + return t("Skipped"); + case "disputed": + return t("Disputed"); + case "unavailable": + return r.timedOut ? t("Timed out") : t("Unavailable"); + case "failed": + if (r.kind === "command") return r.exitCode === null || r.exitCode === undefined ? t("Stopped") : t("Exit {code}", { code: r.exitCode }); + return ""; + default: + return r.kind === "command" && r.ms ? seconds(r.ms) : ""; + } +} + +/** One checked item: icon, name, result; failures open with their output or evidence (§7.1). */ +const ItemRow = memo(function ItemRow({ item }: { item: ReviewItem }) { + const { colors } = useTheme(); + const evidence = item.kind === "command" ? item.tail : item.detail; + const body = item.state === "disputed" ? item.disputeReason || item.detail : item.state === "skipped" || item.state === "unavailable" ? evidence || item.reason : evidence; + const where = item.file ? `${item.file}${item.line ? `:${item.line}` : ""}` : ""; + const more = !!body || !!where || !!item.quote || (item.kind === "command" && !!item.argv); + // Unset until the user toggles, so a row that fails while the drawer is open still opens. + const [toggled, setOpen] = useState(null); + const open = toggled ?? item.state === "failed"; + const icon = + item.state === "passed" + ? { name: "CircleCheck", color: colors.statusSuccess } + : item.state === "failed" + ? { name: "CircleX", color: colors.statusDanger } + : item.state === "disputed" + ? { name: "CircleAlert", color: colors.statusWarning } + : item.state === "running" + ? { name: "Loader", color: colors.accent } + : item.state === "na" + ? { name: "CircleMinus", color: colors.foregroundMuted } + : item.state === "unavailable" + ? { name: "CircleHelp", color: colors.statusWarning } + : { name: "CircleDashed", color: colors.foregroundMuted }; + const name = item.kind === "goal" ? t("Does what you asked") : item.name; + const result = resultText(item); + return ( + + setOpen(!open)} + style={{ ...noFocusRing, flexDirection: "row", alignItems: "center", gap: space[1.5] }} + > + + + + + {item.kind === "command" && !item.gating && ( + + )} + {!!result && ( + + )} + + {open && more && ( + + {item.kind === "command" && !!item.argv && ( + + )} + {!!where && ( + + )} + {!!item.quote && ( + + )} + {!!body && ( + + )} + + )} + + ); +}); + +/** Whether a person can rerun the card's review now (AUTO-REVIEW-SPEC §9.2). */ +export const canRerun = (task: Task, project: Project, disabled: boolean) => + !!task.review && !disabled && task.status === "in_review" && task.review.state !== "running" && autoReviewRuns(project); + +/** Rerun the card's review; failures toast. */ +export function useRerun(taskId: string) { + const toast = useToast(); + const queryClient = useQueryClient(); + const rerunRpc = useRpc(rpc.rerunReview); + const [busy, setBusy] = useState(false); + const rerun = async () => { + setBusy(true); + try { + await rerunRpc({ taskId }); + await invalidateCardViews(queryClient); + } catch (e) { + toast.show(message(e), { variant: "error" }); + } finally { + setBusy(false); + } + }; + return { busy, rerun }; +} + +export function RerunButton({ taskId }: { taskId: string }) { + const { busy, rerun } = useRerun(taskId); + return void rerun()} />; +} + +/** + * The Workpad's Review group (GUARDRAILS-SPEC §7.1): the latest review's state, round and model, then every + * checked item, failures first. + */ +export function ReviewBlock({ review }: { review: Review }) { + const meta = [ + reviewLabel(review), + t("Round {round}", { round: review.round }), + review.model ?? "", + ].filter(Boolean); + return ( + + + {!!review.reason && ( + + )} + {review.shared && ( + + )} + {sortedItems(review.items ?? []).map((item) => ( + + ))} + + ); +} diff --git a/client/task-workpad.tsx b/client/task-workpad.tsx index 81e2cca8..ac24e2bf 100644 --- a/client/task-workpad.tsx +++ b/client/task-workpad.tsx @@ -4,129 +4,104 @@ import { memo, useEffect, useMemo, useState, type ReactNode } from "react"; import { Pressable, View } from "react-native"; import { Icon, useToast } from "@getpaseo/plugin/client/react-native"; import { useRpc } from "@getpaseo/plugin/client"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import * as rpc from "../shared/rpc"; -import type { AcceptanceItem, CheckState, ConfusionItem, PlanSnapshot, Task, ValidationItem, Workpad } from "../shared/model"; -import { plansOf, validationFreshness, workpadOf } from "../shared/workpad"; -import { useLiveAgents } from "./queries"; -import { Collapse, PagedRows, Section, ShowMore, pageSize, type SectionState } from "./disclosure"; +import type { AcceptanceItem, PlanSnapshot, Project, Task } from "../shared/model"; +import { plansOf, workpadEnabled } from "../shared/workpad"; +import { useGuardrailsQuery, useLiveAgents } from "./queries"; +import { PagedRows, Section, ShowMore, pageSize, type SectionState } from "./disclosure"; import { FocusTarget, Marked, limitFor, useFocusTarget, useMark } from "./drawer-focus"; -import { AiMark, IconButton, Label, font, message, radius, relativeTime, space, touch, useTheme, noFocusRing } from "./ui"; -import { t } from "../shared/i18n"; - -const firstPage = { acceptance: pageSize, checks: pageSize, confusions: pageSize }; - -/** What needs a look first: failed, out of date, not run, then passed (stable within each). */ -function checkOrder(checks: readonly T[], head: Workpad["head"]): T[] { - return checks - .map((item, index) => ({ item, index, rank: item.state === "fail" ? 0 : item.state === "unrun" ? 2 : validationFreshness(item, head) === "stale" ? 1 : 3 })) - .sort((a, b) => a.rank - b.rank || a.index - b.index) - .map((x) => x.item); -} - -/** The next state when a person taps a check's status (WORKPAD-SPEC §6.3). */ -const nextState: Record = { unrun: "pass", pass: "fail", fail: "unrun" }; - -/** Home directory shortened for display; the full path stays selectable in the stamp. */ -const shortPath = (path: string) => path.replace(/^\/(Users|home)\/[^/]+/, "~"); +import { ReviewBlock, RerunButton, canRerun, reviewLabel } from "./task-review"; +import { AiMark, Label, font, message, radius, relativeTime, space, touch, useTheme, noFocusRing } from "./ui"; +import { t, tn } from "../shared/i18n"; /** - * Drawer "Workpad" (WORKPAD-SPEC §6.2): the agent's acceptance criteria and - * checks, the plan from its own todo list and what confused it. People can - * tick criteria, set a check's state and resolve confusions; edits are - * optimistic and never change the task revision. + * Drawer "Workpad" (GUARDRAILS-SPEC §7.1), the card's own account: the agent's acceptance list, what this card was + * sent back for, the latest review and the plan from the agent's todo list. Groups without content are not shown. + * People can tick acceptance items; edits are optimistic and never change the task revision. */ -export const TaskWorkpad = memo(function TaskWorkpad({ task, disabled, active, section }: { task: Task; disabled: boolean; active: boolean; section: SectionState }) { +export const TaskWorkpad = memo(function TaskWorkpad({ + task, + project, + guardrails, + disabled, + active, + section, +}: { + task: Task; + project: Project; + /** Guardrails on for this project (host and project switch): show this card's items and notes. */ + guardrails: boolean; + disabled: boolean; + active: boolean; + section: SectionState; +}) { usePerfRender("switch.workpad.commit"); const switchStart = perfStart(); const { detail } = useTaskDetail(); const toast = useToast(); const queryClient = useQueryClient(); const checkRpc = useRpc(rpc.checkWorkpad); - const resolveRpc = useRpc(rpc.resolveConfusion); - const refreshRpc = useRpc(rpc.refreshWorkpad); - const workpad = detail?.workpad ?? undefined; - const plans = useMemo(() => plansOf(detail?.plans || [], task.id), [detail?.plans, task.id]); - // Optimistic edits by item id until the board shows them. - const [pending, setPending] = useState & Pick> | "removed">>( - () => new Map(), - ); + const enabled = workpadEnabled(project); + const workpad = enabled ? (detail?.workpad ?? undefined) : undefined; + const plans = useMemo(() => (enabled ? plansOf(detail?.plans || [], task.id) : []), [enabled, detail?.plans, task.id]); + // This card's items and notes come from the project's guardrails, read while the sidebar shows (§7.4). + const store = useGuardrailsQuery(project.id, active && guardrails).data; + const own = useMemo(() => { + if (!guardrails || !store?.enabled) return { items: [], notes: [] }; + return { + items: store.items.filter((i) => i.taskId === task.id && i.state !== "closed"), + notes: store.entries.filter((e) => e.kind === "note" && e.state === "active" && (e.source?.taskId ?? e.sources[0]?.taskId) === task.id), + }; + }, [guardrails, store, task.id]); + const review = task.review; + // Optimistic ticks by item id until the board shows them. + const [pending, setPending] = useState>(() => new Map()); const agentIds = plans.map((p) => p.agentId).concat(workpad?.agentId ? [workpad.agentId] : []); const titleKey = useLiveAgents({ poll: false, select: (agents) => JSON.stringify(agents.filter((a) => agentIds.includes(a.id)).map((a) => [a.id, a.title || a.id])), }).data; const titles = useMemo(() => new Map(titleKey ? JSON.parse(titleKey) : []), [titleKey]); - // A fresh HEAD when the section shows, so out-of-date checks are current (§5.2). - const head = useQuery({ - queryKey: ["flowly", "workpad-head", task.id], - queryFn: () => refreshRpc({ taskId: task.id }), - enabled: active && !!workpad?.validation.some((v) => v.state !== "unrun"), - staleTime: 30_000, - retry: false, - }); // Long lists show 5 items, then Show more adds 5; per opening of the sidebar (DRAWER-SPEC, Section order). - const [limit, setLimit] = useState(firstPage); + const [limit, setLimit] = useState(pageSize); useEffect(() => { - if (active) setLimit(firstPage); + if (active) setLimit(pageSize); }, [active]); - const more = (group: keyof typeof firstPage) => setLimit((current) => ({ ...current, [group]: current[group] + pageSize })); // A search hit on an item past the first page shows the pages up to it (DRAWER-SPEC "Focus from search"). const focusTarget = useFocusTarget(); useEffect(() => { if (!focusTarget.startsWith("workpad:") || !workpad) return; - const id = focusTarget.slice("workpad:".length); - const groups: [keyof typeof firstPage, readonly { id: string }[]][] = [ - ["acceptance", workpad.acceptance], - ["checks", checkOrder(workpad.validation, workpad.head)], - ["confusions", workpad.confusions], - ]; - for (const [group, list] of groups) { - const at = list.findIndex((item) => item.id === id); - if (at >= 0) setLimit((current) => ({ ...current, [group]: limitFor(at, current[group], pageSize) })); - } + const at = workpad.acceptance.findIndex((item) => item.id === focusTarget.slice("workpad:".length)); + if (at >= 0) setLimit((current) => limitFor(at, current, pageSize)); }, [focusTarget, workpad]); - const storedSha = workpad?.head?.sha || "", - storedDirty = !!workpad?.head?.dirty; - useEffect(() => { - if (!head.data) return; - if ((head.data.head?.sha || "") !== storedSha || !!head.data.head?.dirty !== storedDirty) - void invalidateCardViews(queryClient); - }, [head.data, storedSha, storedDirty, queryClient]); - if (!workpad && !plans.length) return null; - const edit = async (id: string, change: Partial & Pick> | "removed", run: () => Promise) => { - setPending((current) => new Map(current).set(id, change)); + const hasThisCard = own.items.length > 0 || own.notes.length > 0; + if (!workpad && !plans.length && !review && !hasThisCard) return null; + const tick = async (item: AcceptanceItem) => { + const done = !item.done; + setPending((current) => new Map(current).set(item.id, done)); try { - await run(); + await checkRpc({ taskId: task.id, section: "acceptance", itemId: item.id, done }); await invalidateCardViews(queryClient); } catch (e) { toast.show(message(e), { variant: "error" }); } finally { setPending((current) => { const next = new Map(current); - next.delete(id); + next.delete(item.id); return next; }); } }; const acceptance = (workpad?.acceptance || []).map((a) => { - const p = pending.get(a.id); - return p && p !== "removed" && p.done !== undefined ? { ...a, done: p.done, by: "human" as const } : a; - }); - const checks = (workpad?.validation || []).map((v) => { - const p = pending.get(v.id); - return p && p !== "removed" && p.state ? { ...v, state: p.state, by: "human" as const } : v; + const done = pending.get(a.id); + return done !== undefined ? { ...a, done, by: "human" as const } : a; }); - const shownChecks = checkOrder(checks, workpad!.head); - const confusions = (workpad?.confusions || []).filter((c) => pending.get(c.id) !== "removed"); const updatedBy = !workpad ? "" : workpad.updatedBy === "human" ? t("Updated {time} by you", { time: relativeTime(workpad.updatedAt) }) : t("Updated {time} by {session}", { time: relativeTime(workpad.updatedAt), session: titles.get(workpad.agentId || "") || t("an agent") }); - const stamp = workpad?.env - ? `${workpad.env.host}:${shortPath(workpad.env.path)}${workpad.env.head?.sha ? `@${workpad.env.head.sha.slice(0, 7)}${workpad.env.head.dirty ? "*" : ""}` : ""}` - : ""; perfEnd("switch.workpad.own", switchStart); return (
a.done).length, total: acceptance.length }) : "", - checks.length ? t("Checks {done}/{total}", { done: checks.filter((c) => c.state === "pass").length, total: checks.length }) : "", + review ? reviewLabel(review) : "", ].filter(Boolean).join(" · ")} > - {!!workpad && ( - - {!!stamp && ( - - )} - - - )} {!!acceptance.length && ( - - ( + + ( - - void edit(item.id, { done: !item.done }, () => - checkRpc({ taskId: task.id, section: "acceptance", itemId: item.id, done: !item.done }), - ) - } - /> + void tick(item)} /> )} /> - {acceptance.length > limit.acceptance && more("acceptance")} />} + {acceptance.length > limit && setLimit(limit + pageSize)} />} )} - {!!checks.length && ( - - ( - - - void edit(item.id, { state: nextState[item.state] }, () => - checkRpc({ taskId: task.id, section: "validation", itemId: item.id, state: nextState[item.state] }), - ) - } - /> - - )} /> - {shownChecks.length > limit.checks && more("checks")} />} + {hasThisCard && ( + + {own.items.map((item) => ( + + ))} + {own.notes.map((note) => ( + + ))} + + )} + {!!review && ( + : undefined}> + )} {!!plans.length && ( @@ -196,25 +147,11 @@ export const TaskWorkpad = memo(function TaskWorkpad({ task, disabled, active, s ))} )} - {!!confusions.length && ( - - ( - - void edit(item.id, "removed", () => resolveRpc({ taskId: task.id, itemId: item.id }))} - /> - - )} /> - {confusions.length > limit.confusions && more("confusions")} />} - - )}
); }); -function Group({ title, note, children }: { title: string; note?: string; children: ReactNode }) { +function Group({ title, note, action, children }: { title: string; note?: string; action?: ReactNode; children: ReactNode }) { return ( @@ -222,9 +159,17 @@ function Group({ title, note, children }: { title: string; note?: string; childr {title} {!!note && ( - + + + + )} + {!!action && ( + <> + {!note && } + {action} + )} {children} @@ -234,6 +179,26 @@ function Group({ title, note, children }: { title: string; note?: string; childr const rowStyle = () => ({ flexDirection: "row", alignItems: "flex-start", gap: space[2], paddingVertical: 3 }) as const; +/** A thing this card was sent back for (the user's words, never translated) or a note it left for the project. */ +function ThisCardRow({ icon, quote, text, note }: { icon: string; quote?: string; text?: string; note: string }) { + const { colors } = useTheme(); + return ( + + + + + + + + + + ); +} + function Box({ checked }: { checked: boolean }) { const { colors } = useTheme(); return ( @@ -255,14 +220,6 @@ function Box({ checked }: { checked: boolean }) { ); } -function ByYou({ by }: { by: "agent" | "human" }) { - return by === "human" ? ( - - ) : null; -} - function AcceptanceRow({ item, disabled, onToggle }: { item: AcceptanceItem; disabled: boolean; onToggle(): void }) { const mark = useMark(`workpad:${item.id}`); return ( @@ -279,95 +236,15 @@ function AcceptanceRow({ item, disabled, onToggle }: { item: AcceptanceItem; dis - + {item.by === "human" && ( + + )} ); } -function stateLabel(item: ValidationItem, stale: boolean) { - if (item.state === "fail") return t("failed"); - if (item.state === "unrun") return t("not run"); - return stale ? t("out of date") : t("passed"); -} - -function CheckRow({ item, workpad, disabled, onCycle }: { item: ValidationItem; workpad: Workpad; disabled: boolean; onCycle(): void }) { - const { colors } = useTheme(); - const mark = useMark(`workpad:${item.id}`); - const [open, setOpen] = useState(false); - // A search hit on this check shows the whole command and its output (DRAWER-SPEC "Focus from search" 7). - useEffect(() => { - if (mark) setOpen(true); - }, [mark]); - const stale = validationFreshness(item, workpad.head) === "stale"; - const icon = - item.state === "pass" - ? { name: stale ? "TriangleAlert" : "CircleCheck", color: stale ? colors.statusWarning : colors.statusSuccess } - : item.state === "fail" - ? { name: "CircleX", color: colors.statusDanger } - : { name: "CircleDashed", color: colors.foregroundMuted }; - const details = [ - item.sha ? `@${item.sha.slice(0, 7)}` : "", - item.dirty ? t("on uncommitted changes") : "", - item.ranAt && item.state !== "unrun" ? relativeTime(item.ranAt) : "", - ].filter(Boolean); - return ( - - ({ ...noFocusRing, marginTop: 1, opacity: pressed ? 0.6 : 1 })} - > - - - - - {!!item.command && ( - // One line until the output is shown (or the command is tapped), so long commands don't fill the sidebar. - setOpen(!open)} style={noFocusRing}> - - - )} - {(details.length > 0 || stale) && ( - - {stale && ( - - )} - {details.length > 0 && ( - - )} - - )} - {!!item.evidence && ( - <> - setOpen(!open)} style={noFocusRing}> - - - - - - - )} - - - - ); -} - function PlanList({ plan, title }: { plan: PlanSnapshot; title: string }) { const { colors } = useTheme(); return ( @@ -399,17 +276,3 @@ function PlanList({ plan, title }: { plan: PlanSnapshot; title: string }) { ); } - -function ConfusionRow({ item, disabled, onResolve }: { item: ConfusionItem; disabled: boolean; onResolve(): void }) { - const { colors } = useTheme(); - const mark = useMark(`workpad:${item.id}`); - return ( - - - - - - - - ); -} diff --git a/client/tour-demos.tsx b/client/tour-demos.tsx index 55c31d6e..3fc56d03 100644 --- a/client/tour-demos.tsx +++ b/client/tour-demos.tsx @@ -7,9 +7,7 @@ import { TaskCard } from "./card"; import { CardFootnote, ContentCard } from "./content-card"; import { Collapse } from "./disclosure"; import { Enter, MotionReveal, useReducedMotion } from "./motion"; -import { loopAutoStep, memoryAutoStep, type LoopAutoStep, type MemoryPhase } from "./onboarding-logic"; -import { MemoryCard, SourceLine, type MemoryActions } from "./memory"; -import type { MemoryEntry } from "../shared/memory"; +import { loopAutoStep, type LoopAutoStep } from "./onboarding-logic"; import { NativeCardHeading } from "./native-card"; import { NextMoves } from "./next-moves"; import { StatusIcon } from "./status"; @@ -543,159 +541,3 @@ function LoopCase({ auto, onAuto, onStartOver }: { auto: boolean; onAuto(on: boo ); } - -// ——— Project memory ——— - -/** The pitfall a finished card taught; `phase` decides how far it got. */ -function sampleMemory(phase: MemoryPhase): MemoryEntry { - const confirmed = phase !== "suggested"; - return { - id: "tour-memory", - projectId: "tour", - kind: "pitfall", - title: t("Never open a dialog in the frame another one closes."), - body: "", - tags: [], - keywords: [], - pinned: false, - status: confirmed ? "active" : "candidate", - source: { by: "retro", taskId: "tour-freeze" }, - revision: 1, - createdAt: ago(3), - updatedAt: ago(1), - ...(confirmed ? { confirmedAt: ago(1) } : {}), - ...(phase === "used" ? { usedAt: ago(0), usedBy: [{ taskId: "tour-jira", at: ago(0) }] } : {}), - }; -} - -const memorySession = { - ask: () => t("Add a Jira connection dialog to Settings."), - work: (): Said[] => [{ type: "reasoning", text: t("Settings closes as the new dialog opens: project memory says to wait until it has closed.") }, tool("Read"), tool("Edit")], - reply: () => t("Added **Add Jira connection** to Settings. Project memory warns that opening a dialog in the frame another one closes freezes Paseo, so it opens once Settings has finished closing."), -}; - -/** - * Project memory on one case (ONBOARDING-SETUP-SPEC §3, page 3): a finished card's review suggested a pitfall. - * Confirm it and a later card's session starts with it and gets it right; Dismiss and no card sees it. - */ -export function MemoryDemo() { - const [run, setRun] = useState(0); - const [auto, setAuto] = useState(!useReducedMotion()); - return setRun((n) => n + 1)} />; -} - -function MemoryCase({ auto, onAuto, onStartOver }: { auto: boolean; onAuto(on: boolean): void; onStartOver(): void }) { - const { colors } = useTheme(); - const narrow = useDemoNarrow(); - const later = useLater(); - const [phase, setPhase] = useState("suggested"); - const [said, setSaid] = useState([]); - const [cue, setCue] = useState(false); - const confirm = () => { - if (phase !== "suggested") return; - setPhase("confirmed"); - // A later card starts: its session gets the confirmed memory with its first instructions. - later(() => { - const work = memorySession.work(); - setPhase("working"); - setSaid([{ type: "user_message", text: memorySession.ask(), messageId: "tour-jira" }]); - work.forEach((step, index) => later(() => setSaid((current) => [...current, step]), 300 + index * 350)); - later(() => { - setSaid((current) => [...current, { type: "assistant_message", text: memorySession.reply() }]); - setPhase("used"); - }, 300 + work.length * 350 + 300); - }, 900); - }; - const autoRef = useRef(auto); - autoRef.current = auto; - useEffect(() => { - setCue(false); - if (!auto) return; - const step = memoryAutoStep(phase); - if (!step) return; - const ring = setTimeout(() => { - if (step.cue === "restart") return onStartOver(); - setCue(true); - later(() => autoRef.current && confirm(), 700); - }, step.wait); - return () => clearTimeout(ring); - }, [auto, phase]); - // Returns what the action returns: MemoryCard closes its editor only when confirm resolves true. - const own = (run: (...args: A) => R) => (...args: A): R => { - onAuto(false); - return run(...args); - }; - // MemoryCard's own buttons drive the demo; everything else it could do is inert here. - const actions = useMemo( - () => - new Proxy( - { busy: null, confirm: own(async () => (confirm(), true)), dismiss: own(async () => (setPhase("dismissed"), true)) }, - { get: (target, key) => (key in target ? target[key as keyof typeof target] : async () => false) }, - ) as unknown as MemoryActions, - [phase], - ); - const entry = sampleMemory(phase); - const hint = auto - ? t("Playing it for you. Press anything to try it yourself.") - : phase === "suggested" - ? t("Try it: Confirm shares it with the project's later cards.") - : ""; - return ( - - - - - {t("Project memory")} - {/* Scrolls when Edit opens the editor in the card's place. */} - - {phase === "dismissed" ? ( - - - - ) : ( - - onAuto(false)} - source={ statusLabels[status]} onOpenTask={noop} />} - /> - - )} - - - - {t("A later card's session")} - {said.length ? ( - - - - - - } - /> - - ) : ( - - - - )} - - -
- ); -} diff --git a/client/whats-new.ts b/client/whats-new.ts index 82325fc9..05a2cbfa 100644 --- a/client/whats-new.ts +++ b/client/whats-new.ts @@ -6,7 +6,8 @@ export interface WhatsNewItem { icon: string; /** One sentence, msg()-marked; translated at render. */ text: string; - action?: { label: string; to: "settings"; section: SettingsSection } | { label: string; to: "tour" }; + /** `auto-review`: the current project's Edit project with Auto review switched on (GUARDRAILS-SPEC §10). */ + action?: { label: string; to: "settings"; section: SettingsSection } | { label: string; to: "tour" } | { label: string; to: "auto-review" }; } export interface WhatsNewRelease { @@ -22,9 +23,22 @@ export interface WhatsNewRelease { */ export const whatsNew: WhatsNewRelease[] = [ { - // SEARCH-SPEC §8. + // SEARCH-SPEC §8, GUARDRAILS-SPEC §10. version: "1.8.0", - items: [{ icon: "Search", text: msg("Search past cards, summaries, notes, workpads, ideas and deliverables. Agents can search them too.") }], + items: [ + { icon: "Search", text: msg("Search past cards, summaries, notes, workpads, ideas and deliverables. Agents can search them too.") }, + // GUARDRAILS-SPEC §10 (D1): on for new projects; existing ones turn it on here. + { + icon: "ShieldCheck", + text: msg("Auto review checks every delivery before it reaches you and sends it back to the agent when it falls short. On for new projects."), + action: { label: msg("Turn on for this project"), to: "auto-review" }, + }, + { + icon: "Undo2", + text: msg("Flowly learns from what you send cards back for: the second card with the same problem turns it into a project guideline, checked on every delivery it applies to."), + }, + { icon: "BookMarked", text: msg("Memory no longer needs confirming; what agents remember takes effect at once and retires by itself when unused.") }, + ], }, { // INSIGHTS-REDESIGN-SPEC §5: Skills turn on for everyone who never touched the switch. diff --git a/docs/AUTO-REVIEW-SPEC.md b/docs/AUTO-REVIEW-SPEC.md new file mode 100644 index 00000000..f56f4c91 --- /dev/null +++ b/docs/AUTO-REVIEW-SPEC.md @@ -0,0 +1,517 @@ +# 交付自动验收(AUTO-REVIEW-SPEC) + +> 2026-10-28:本文部分内容被 [GUARDRAILS-SPEC](GUARDRAILS-SPEC.md) 取代(§12)。被取代:§1.1 用户路径、§4.1 L1、§4.2 的规则种类与 `types`、§6 全部配置、§7 Insights、§8.1 的 rules / guidance / reviewDraft、§8.4、§8.5 的多数 RPC、§9.2 的“Auto review 在 Workpad 之前、Workpad 默认收起”(评审现在是 Workpad 里的 Review 组,Workpad 默认展开)、§9.3、§9.6 的 What's new、§12 P3 的 2、4、5、D7、D8、D10、D11、D13–D16;仍有效:§2、§3、§4.3、§4.4 的 session 跑法、§4.5、§5、§8.2 骨架、§8.3、§9.1、§9.2 的 Rerun、§9.4、D1–D6、D9、D12、D17。 + +状态:P1、P2、P3 已实现,未提交(§12)。决定见 §11。卡片 `4286f00a`(Auto Review)。 + +## 1. 目标与用户路径 + +一句话:**打开 Auto review 后,agent 每次交付都先经过检查:Flowly 跑项目的检查命令,再由独立的评审者确认改动做到了用户在卡片和对话里要求的事;没做到就退回给 agent 返工,做到了才提醒用户。** + +用户不需要设计规则。默认配置能直接用;卡片要查得更细时直接对 agent 说;规则随使用由 Insights 根据用户实际纠正过的事推荐。 + +### 1.1 用户路径 + +| 阶段 | 用户做的 | 用户看到的 | +| --- | --- | --- | +| 新用户 | 无 | 不推。导览 More features 里一句被动介绍(§9.6) | +| 新用户用了一阵,交付后常返工 | 在 Insights 点 **Set up auto review** | Insights 顶部:“最近 14 天交付的 N 张卡里,有 M 张交付后你又让 agent 改了”,附卡片(§7.1)。点击进入历史分析(§6.2) | +| 老用户升级到 1.8.0 | 在 What's new 点 **Suggest checks from past cards** | 当前项目的门禁总结与预选好的检查(§6.2) | +| 任何人主动打开 | Edit project → 打开 Auto review → 勾选识别到的命令 → Use these | 内置检查 + 识别到的命令(§6.1);有足够历史时可点 **Suggest checks from past cards** | +| 日常 | 照常和 agent 干活 | 交付后卡面 “Reviewing 1/3”;通过后 Attention 提醒,抽屉先显示评审结果;没过时卡片自己回到工作阶段,agent 收到返工消息 | +| 复杂的卡 | 在会话里说“这个涉及迁移,重点查旧数据能不能读” | 内置检查读到这句话,按它判定(§4.2) | +| 返工到上限仍不过 | 回复 agent 或接手 | 卡片在 Blocked,Attention 显示 Review failed | +| 用了一阵 | 在 Insights 点 **Add check** | Insights 推荐由用户反复纠正长出来的检查,或建议暂停从没失败过的检查(§7.2) | +| 少数人细调 | 项目设置里编辑检查,或 **Adjust with AI** | 规则列表;设置卡片里与 agent 对话后 Apply 草案(§6.3) | + +### 1.2 不做 + +人拖进 In review 时触发;评审 agent 改代码;多个评审者投票;CI/PR 状态接入(PR 自动化不在 v1 范围内);外部推送;清除 Paseo 原生的回合结束提醒(D4);临时 worktree 隔离(D6);卡片自己的规则列表、评审等级、按改动路径触发的规则(D11)。 + +## 2. 复用与已知限制 + +| 需要 | 现有机制 | 用法 | +| --- | --- | --- | +| 交付时的 workpad 检查 | `openItems()` + `WORKPAD_OPEN` / `acknowledgeOpen`(WORKPAD-SPEC §5.3,`server/delivery-gate.ts`) | L1 在其上加硬拒绝 | +| 验证是否在当前代码上跑的 | `validationFreshness()`(`shared/workpad.ts`) | 直接用 | +| 交付前的阶段 | 进入 In review 的 `status_changed` 带 `from` | 退回目标(§3.2) | +| 退回后继续 | A1 回到最近的工作阶段(`resumeStage()`,`server/progress-rules.ts`) | 直接用 | +| 临时模型 session、结构化输出、超时、硬删除 | 摘要器:`agents.create({ outputSchema, labels })` + `runSummaryModels` + `summarizer-remove.ts` | 评审 session | +| 项目级的临时 session | Flowly background workspace(BACKGROUND-WORKSPACE-SPEC) | 历史分析(§6.2) | +| 给已有 session 发消息 | composer 的发送队列 | 返工投递 | +| 读 session 里用户的话 | `timeline.refetch({ projection: "projected" })` 的 `user_message`;项目记忆的线索(PROJECT-MEMORY-SPEC §4.0) | 内置检查、历史分析、返工提示 | +| 定期分析 | Insights 运行(INSIGHTS-REDESIGN-SPEC,WORKFLOW-INSIGHTS-SPEC §4) | 检查建议(§7.2) | + +已知限制: + +1. **Paseo 原生提醒拦不住。** 交付那一轮结束时 Paseo 会提醒 “agent 完成了”,这时评审还没开始;Flowly 只控制自己的 Attention,在卡片上标注评审状态(D4)。 +2. **Paseo 脚本拿不到退出码。** 命令检查由 daemon 用参数数组直接起子进程。 +3. **共享工作区会互相干扰。** 多张卡共用项目根目录时,命令测到的是所有卡的改动合在一起的状态。不隔离(D6),结果标注“共享工作区”,记录 sha 和 dirty。 +4. **workpad 里的命令不自动执行。** 那是被评审的 agent 写的,照着执行等于让它绕过 Paseo 的权限确认。daemon 只执行项目规则里的命令,规则只能由用户写入或确认(§6)。 + +## 3. 流程与状态 + +### 3.1 触发 + +**agent** 调用 `flowly_update_status(in_review)`,通过 L1 并成功提交,项目开启了 Auto review,卡片未归档、不是设置卡片(§6.3),且有对这张卡生效的检查(§4.2)。人拖进 In review 不触发。 + +评审在**交付的那个 session 这一轮结束后**才开始(`agent.turn_ended`);交付时该 session 已空闲则立即开始。交付之后同一轮里 agent 还可能提交、改 workpad,这些会改变指纹让评审作废。等待中的交付只记在内存里:等待期间插件重启,这次交付不评审,卡片留在 In review,人可以按 Rerun。 + +```mermaid +stateDiagram-v2 + [*] --> L1: agent 交付到 In review + L1 --> [*]: fail/stale → 当场拒绝(WORKPAD_FAILING),卡片不动 + L1 --> Reviewing: 通过,提交到 In review + Reviewing --> Passed: 所有检查通过或不适用 + Reviewing --> Rework: 有检查不通过,且已返工次数 < maxRounds + Reviewing --> Failed: 有检查不通过,且已返工次数 = maxRounds + Reviewing --> Unavailable: 评审本身出错 + Reviewing --> Cancelled: 指纹变化(人移动卡片 / 新一轮开始 / 新提交) + Rework --> L1: agent 修改后再次交付 + Passed --> [*]: 留在 In review,放开被压住的未读 + Failed --> [*]: 移到 Blocked,进入 Attention + Unavailable --> [*]: 留在 In review,进入 Attention +``` + +同一项目同一时间只跑一个评审(排队),因为它们的卡可能共用目录。 + +### 3.2 各状态 + +- **Reviewing**:卡片在 In review。这张卡在 Flowly Attention 里的**未读**信号暂不计入;权限请求、错误、提问照常计入。 +- **Rework(R1)**: + 1. 退回目标 = 触发这次评审的交付事件的 `from`。它是 Design、Development 或 Test 且泳道开启时回到那里;否则(`from` 是非工作阶段、泳道已关闭、事件已被裁剪)按 `resumeStage()` 的 Blocked 分支取最近的工作阶段;仍没有时用第一个开启的工作阶段。 + 2. 同一次写入里改状态(actor=system,`rule: "R1"`,task revision +1),**然后**发返工消息(§5)。状态先离开 In review,新一轮开始时 A1 就不会把卡改到 Development。 + 3. 项目关闭 autoStatus 时 R1 照常运行。 +- **Failed(R2)**:返工次数已达 `maxRounds` 仍不通过,不再发消息;卡片移到 Blocked(actor=system,`rule: "R2"`,note “Auto review failed after N rework rounds”),进入 Attention。Blocked 泳道关闭时留在 In review。之后用户在该卡 session 里回复,A1 照常把卡拉回最近的工作阶段。 +- **Unavailable**:评审机制本身出错:工作目录不可用、命令起不来或超时、评审 session 失败或输出无法解析、返工 session 已不在、评审进行中 Flowly 重启。卡片不动(R1 之后投递失败时保持退回状态),进入 Attention 并显示原因。不计返工次数。 +- **Cancelled**:开始时记录指纹(task revision、workpad revision、HEAD sha、dirty)。写结果前指纹变了就丢弃结果:不改状态、不发消息、不进 Attention。卡片仍在 In review 时显示 “Review cancelled”,可 Rerun。共享工作区里别的卡提交也会让 HEAD 变化而取消。 +- 通过后从不自动移到 Done。 + +### 3.3 返工计数 + +- `round` 从 1 开始,每次 Rework 后再交付 +1;已返工次数 = `round - 1`。`maxRounds = 2` 表示最多自动返工 2 次、最多评审 3 次;`maxRounds = 0` 表示第一次不通过就进 Blocked。 +- 重置为 1:上一次评审结束后人移动了卡片;该卡开始了不止一个回合(返工消息本身开始一个回合,多出来的是人发的消息);上一次评审是 Passed/Failed/Unavailable/Cancelled;人按 Rerun。 + +## 4. 检查 + +### 4.1 L1 Workpad(同步,D3) + +在 `flowly_update_status(in_review)` 里、交付提交之前计算。只在项目开启 Auto review 时生效。 + +| workpad 状态 | 结果 | +| --- | --- | +| 没有 workpad | 照现有 `SETUP_INCOMPLETE`(先拒后放) | +| 有 `fail` 的验证项 | 拒绝,`acknowledgeOpen` 无效 | +| 有 `stale` 的 pass | 拒绝,`acknowledgeOpen` 无效 | +| 有未勾选的验收标准,或 `unrun` 的验证项 | 照现有 `WORKPAD_OPEN`(先拒后放) | + +错误码 `WORKPAD_FAILING`(不提交、不写回执、可重试):“Auto review is on for this project: {n} workpad checks failed or are out of date: … Fix them and rerun the checks on the current code before delivering. acknowledgeOpen does not apply.” workpad 是 agent 的自报,L1 只拦 agent 自己承认的问题;它不是评审的尺子(D12)。 + +### 4.2 检查的种类 + +| 种类 | 由谁判定 | 内容 | +| --- | --- | --- | +| **内置检查 “Does what you asked”** | 评审 session | 固定,对照卡片标题、描述和用户在这张卡会话里说的话判断改动是否做到了(D12) | +| **命令检查** | daemon 跑命令,退出码 0 为通过 | `argv`、可选超时 | +| **LLM 检查** | 评审 session | `prompt`:一条具体、能从 diff 与卡片判断的要求 | + +界面上统一叫“检查”,用图标区分命令与判断。 + +- **生效的检查**:内置检查(未暂停时)+ 没有 `off`、且 `types` 为空或包含卡片 type 的命令检查和 LLM 检查。卡片没有 type 时所有未暂停的检查都生效。 +- 没有生效的检查时只有 L1,交付不启动评审。 +- **内置检查的尺子**(按这个顺序给评审 session): + 1. 卡片标题和描述; + 2. 用户在这张卡各 session 里发的消息:每个 session 的 projected timeline 末尾 200 条里的 `user_message`,去掉以 `[Flowly auto review` 开头的返工消息,按时间排列,保留最新的 30 条、合计不超过 12 000 字、每条不超过 1 500 字;读不到时写 “(conversation unavailable)”,照样评审; + 3. workpad(验收、验证、困惑点)与交付 note,标明 “the agent's own report, context only”。 +- 内置检查的判定要求:较新的指示覆盖较早的;对话里说过先不做的不算缺;请求太含糊无法判断时判 `na`;不对照 workpad 判定。共享工作区(非 worktree,评审记录 `shared`)时另加一句:diff 里与本卡无关的改动可能属于别的卡,不据此判 fail,只在用户要求的事没做到或做错时判 fail。 +- 内置检查不能删除,只能暂停(`goal: false`)。它的结果 id 固定为 `goal`,其他检查不能用这个 id。 + +### 4.3 命令检查的执行 + +- 评审开始时把生效的检查快照进评审记录;评审中改规则不影响这一次。 +- 命令检查按列表顺序逐条执行,**全部跑完**,每条结束立即写回结果。 +- daemon 在卡片 workspace 目录下用 `spawn`(参数数组,不经 shell,单独的进程组,超时整组 SIGTERM,5 秒后 SIGKILL)。环境变量加 `CI=1`、`NO_COLOR=1`、`FORCE_COLOR=0`。默认超时 600 秒(10–3600)。 +- 退出码 0 为 `passed`;非 0 为 `failed`;超时或起不来为 `unavailable`(不算不通过)。 +- 不通过或 unavailable 的那条保留最后 4 KiB 输出(去掉颜色码),只存在评审记录里,不写日志(可能含 token)。结果不写进 agent 的 workpad。 +- 确实需要 shell 时由规则写成 `["sh","-c","…"]`;那是用户确认过的规则内容,不是 Flowly 拼接的。 + +### 4.4 评审 session(内置检查与 LLM 检查) + +- 只在所有命令检查都 `passed`(或没有命令检查)时跑(D9)。有命令检查 `failed` 时,内置检查和 LLM 检查全部 `skipped`,原因 “Command rules failed.”;有 `unavailable` 时原因 “A command rule could not run.”。 +- 一个临时 session 一次判定全部:建在卡片 workspace 里,父 session 为交付的 session,标签同摘要器(`flowly=summarizer`、`flowly-task`、`flowly-review=1`),结束后硬删除,删除失败时归档并记日志。超时 15 分钟。 +- 模型:Settings → Models 的 **Review model**(`task-summary` 设置的 `reviewModel`);未设置时用项目默认模型;再没有时用摘要模型列表。 +- 输入:卡片标题、描述、type;内置检查的尺子(§4.2);workpad;交付 note 与 outputs;命令检查结果摘要;项目 `guidance`;diff(`git diff HEAD` 加最近 5 个提交的 patch,截断到 64 KiB 并注明);要判定的检查(id、name、prompt)。 +- 指令:只读,不改文件,不运行会改变状态的命令;每条单独判定。 +- 模式(2026-10-30):`config.modeId = readOnlyMode(provider)`(`shared/summary.ts`):`claude/*` 和 `claude-*` 自定义 provider(如 `claude-compass-smith-sqp`)用 `plan`,其他 provider 不设(绝不用 full-access)。否则 Claude 评审 session 在 Always Ask 下第一次调用工具就停在权限确认,`runSummaryModels` 把它当失败,整轮评审 `unavailable`(“The summary model asked for a permission.”)。 +- 权限请求(2026-10-30):没有 Plan Mode 的 provider(Codex 等)仍可能停在权限确认,而临时 session 里没有人能批准。`finishRefusingPermissions`(`server/summary-models.ts`,`runSummaryModels` 和 workflow insights 共用)收到 `permission` 状态时,对 pending 的请求逐个回 deny(附说明让模型不用该工具继续作答),再等这一轮,最多 20 次;请求无法拒绝或超过次数才算失败。这样评审不会因为权限请求而 `unavailable`,卡片也不会因此留在 Attention(§3.2 Unavailable 仍按设计进入 Attention,用于真正跑不起来的情况)。 +- 输出(`outputSchema`,解析宽松:取第一个 JSON 对象,未知 id 和坏条目丢弃): + + ``` + { rules: [{ id, verdict: "pass" | "fail" | "na", detail: string ≤ 600, file?: string, line?: int }] } + ``` + + `na` 按通过处理,显示 “Not applicable”。缺某条的 id → 那条 `unavailable`;session 超时、失败或输出无法解析 → 全部 `unavailable`。 + +### 4.5 总判定 + +有任何一条 `failed` → 不通过(R1 或 R2);否则有 `unavailable` → Unavailable;否则 Passed。 + +## 5. 返工投递与 get_task + +- 返工消息发给**交付的那个 session**;它已被归档或删除时不投递,按 Unavailable 处理(卡片保持 R1 退回后的状态)。session 正在运行时排队,等这一轮结束再发。 +- 消息格式(固定标识开头,与普通 follow-up 区分): + + ``` + [Flowly auto review · rework 1/2] + Commands: 1/2 passed + ✗ Unit tests (npm test) exited 1: + expected 410, got 404 (links.test.ts:88) + LLM review: skipped (command rules failed) + Fix these, rerun the affected checks, update the workpad and deliver to In review again. + ``` + + 命令都通过、判断类检查有不通过时,按 `✗ : (:)` 逐条列出。 +- `flowly_get_task.review`:state、round、maxRounds、reason、startedAt、finishedAt,以及每条检查的 id、kind、name、state、command、exitCode、timedOut、tail(≤ 2000 字)、detail、file、line、reason。`task` 里不重复这份记录。 +- `flowly_get_task.reviewRules`(项目开启时):对本卡生效的检查(含内置检查是否开启)、`maxRounds`,以及只在其中有判断类检查时才带的 `guidance`。设置卡片和当前草案的来源卡片拿全部检查(含暂停的和 `types`)与 `guidance`,并附草案全文;其他卡片有草案时只附来源卡片和时间。设置卡片和草案来源卡片即使项目关闭也带。 +- skill 和 `flowly_update_status` 的说明写明 `WORKPAD_FAILING` 与返工消息:先处理这些意见;评审会对照用户在会话里的要求。 + +## 6. 配置 + +### 6.1 打开开关与命令识别(D13) + +- 在 Edit project 打开 Auto review 时,项目还没有任何命令检查,就调用 `reviews.detect-commands` 从项目根目录识别检查命令,不调用 LLM、不执行命令: + + | 依据 | 识别 | 默认勾选 | + | --- | --- | --- | + | `package.json` 的 scripts,包管理器按锁文件(`pnpm-lock.yaml` → pnpm,`yarn.lock` → yarn,`bun.lockb`/`bun.lock` → bun,否则 npm) | 名为 `typecheck`/`type-check`/`tsc`、`lint`、`test` 的脚本:`[pm, "run", name]` | 是 | + | 同上 | `build`、名字含 `e2e` 的脚本 | 否 | + | `Cargo.toml` | `cargo test` | 是 | + | `go.mod` | `go vet ./...`、`go test ./...` | 是 | + | `pyproject.toml` / `pytest.ini` / `setup.cfg` 提到 pytest | `python -m pytest` | 是 | + | `Makefile` 有 `test`、`lint`、`check` 目标 | `make ` | 是 | + + 最多 6 条,名字带 `watch` 的脚本跳过。 +- 界面在开关下方列出:内置检查(固定在第一行)和识别到的命令(复选框),按钮 **Use these**。勾选的命令写入规则后随 Save 保存(项目 revision +1)。用户勾选就是同意 daemon 执行这些命令。 +- 一条都没识别到时只显示内置检查。 +- 项目在近 90 天有 ≥ 5 次 agent 交付时,同一位置还有按钮 **Suggest checks from past cards**(§6.2)。 + +### 6.2 历史分析与门禁总结(D15、D16) + +**入口**:What's new 1.8.0 的按钮(当前项目);项目设置的 Suggest checks from past cards;Insights 返工提示的 Set up auto review(§7.1)。三处都打开该项目的 Edit project 并开始分析;从 What's new 和 Insights 打开时 Auto review 区块排在表单最前面(宿主的弹窗不提供滚动定位)。 + +**条件**:近 90 天 ≥ 5 次 agent 交付。不满足时不调用模型,显示 “Not enough past deliveries in this project yet.” 和 §6.1 的识别结果。同一项目同时只跑一个分析;已有分析在跑时显示进度。 + +**统计(daemon,确定性)**: + +- 交付:agent 把卡移到 In review 的 `status_changed`。 +- **交付后返工**:卡在 In review 时,用户的消息开始了一轮(A1,`from: in_review`),而且那一轮结束时没有被 A1r 还原(那一轮确实做了事)。“提交并推送”这种不干活的回合会被 A1r 还原,不计入。 +- 门禁总结的数字(交付卡数、交付后返工卡数)由 daemon 算,不信模型。 + +**分析 session**:在项目的 Flowly background workspace 建临时 session(同 Insights:用前恢复或复用,用完归档 workspace;session 结束后硬删除),模型同 Insights 设置的模型,超时 15 分钟。 + +- 素材(总长 ≤ 100 000 字): + 1. 近 90 天交付过的卡:短 ID、标题、type、标签、最新卡片总结的 recap(≤ 400 字)、是否交付后返工; + 2. 返工那一轮用户的原话:项目记忆在那一轮采集的线索(`feedback`、`message`,每卡 ≤ 3 条、每条 ≤ 400 字);项目记忆关闭时写 “(memory off)”; + 3. workpad 里出现过的检查命令及出现的卡数; + 4. §6.1 识别到的命令;`AGENTS.md`/`CLAUDE.md` 的 Commands 一节(≤ 4000 字); + 5. 当前规则(名称、命令或 prompt)。 +- 指令:把交付后返工的原因归类;只为能防止**至少 2 张卡**返工的原因提检查;能由退出码判断的写成命令检查(优先用仓库已有脚本),其余写成一句可判断的 LLM 检查;不重复内置检查和已有规则;最多 6 条。 +- 输出: + + ``` + { reasons: [{ title, cards: string[], quote?: string }], // 返工原因,≤ 6 + checks: [{ rule: Rule, cards: string[], why: string ≤ 300 }] } // Rule 同 §8.1,≤ 6 + ``` + +- 校验:`cards` 必须是素材里的卡,`reasons` 的卡必须是交付后返工的卡;少于 2 张卡的条目丢弃;`rule` 按 §8.1 校验,id 与现有规则冲突时加序号;不合法的丢弃,不截断。全部丢弃时显示总结、没有新检查。 +- 结果写成项目草案(§6.4),`by: { analysis: true }`:`rules` = 当前规则 + 新检查;`added` 列出新检查的 id,每条带 `cards` 与 `why`;`summary` = `{ delivered, reworked, reasons, dropped? }`。写草案前按“当前规则 + 新检查”逐条判断每类 10 条的上限,放不下的新检查丢掉,`dropped` 记丢掉的条数,界面显示 “N suggested checks did not fit: a project holds at most 10 of each kind.”;分析本身不因此失败。 +- 返工回合里用户的原话(素材 2、§7.1 的引用、§7.2 的素材):项目记忆线索中 `feedback`、`message` 两类,时间从这一轮的 A1 起,到该卡下一次 `agent.turn_started` 为止(没有下一轮就到现在)。线索在回合结束之后才写入,所以不能以 `turn_ended` 为界。 + +**界面**(Auto review 区块内): + +``` +过去 90 天交付 34 张卡,其中 9 张交付后你又让 agent 改了 + 测试没跑或没过 4 张 #a1b2 #c3d4 … + 新文案没进所有语言 3 张 #e5f6 …「新文案也要进所有语言」 + +检查 + ✦ Does what you asked + ☑ Typecheck npm run typecheck + ☑ Unit tests npm test 4 张卡 + ☑ 新文案进所有语言目录 3 张卡 +[Use these and turn on] [Adjust with AI] +``` + +卡片引用可点击,打开卡片。新检查默认勾选,取消勾选的在 Apply 时不加入。开关关闭时,§6.1 识别出的命令也并入这个列表(内置检查、识别出的命令、分析给出的检查依次排列,命令同样可取消勾选),只有一个按钮 **Use these and turn on**;勾选的命令随 Apply 一起写入(`apply-draft` 的 `add`)。还没有分析结果时(交付不足、分析失败、分析中)仍按 §6.1 单独显示识别块。分析中显示 “Analyzing past cards… uses tokens”,失败时显示原因,可重试。 + +### 6.3 Adjust with AI(设置卡片,D7) + +- 项目设置的 **Adjust with AI** 按钮(开关关闭时也显示)。项目已有未归档的设置卡片时打开它,否则新建:标题 “Auto review setup”,type `chore`,建在 Design(泳道关闭时用第一条开启的泳道),`task.reviewSetup: true`。daemon 同时建 session:模型取项目默认模型,其次 Settings → Models 的默认模型,都没有时 `claude/claude-haiku-5-5`;第一条消息是设置说明。点击后打开这张卡的抽屉。session 没能启动时卡片照样建好,提示从卡片里启动。 +- 设置卡片交付时不启动评审(它不改代码,要验收的是草案);L1 照常。 +- **设置说明**(英文,agent 读)要求 agent:先读仓库(脚本、CI、`AGENTS.md`/`CLAUDE.md`/`CONTRIBUTING.md`、README)和 `get_task.reviewRules`;用提问工具问用户想在交付时把住什么;能由退出码判断的写成命令检查、优先复用仓库脚本,需要判断的才写成 LLM 检查,每条只管一件事;每条命令在 workspace 里试跑一次,在说明里写出结果和耗时,跑不通的不放进草案;建议 `types`;内置检查已覆盖“做到了用户要求的事”,不要重复;用 `flowly_propose_review_rules` 提交完整列表,用户要改就再提交一次;不改仓库文件,缺检查脚本时先问用户,同意后作为这张卡的普通改动来做。 +- **工具** `flowly_propose_review_rules`(MCP 与 CLI `review-rules`,任何卡片的 session 都可调用):参数 `request`、`expectedRevision`、`rules`(完整列表,§8.1)、`guidance?`、`maxRounds?`、`goal?`(内置检查开关)、`note`(≤ 1000)。校验失败返回 `REVIEW_RULES_INVALID`,列出全部问题,不写入。成功时写入项目草案(§6.4),`by: { taskId, agentId }`,返回与当前规则的差异(新增、修改、删除的 id)。 + +### 6.4 草案与 Apply(D8) + +- 每个项目一份草案,新的覆盖旧的。写入不改项目 revision,不会让其他 agent 的上下文过期。 +- 有草案时,项目设置 Auto review 区块顶部显示草案;来自设置卡片的草案也显示在该卡抽屉里。内容:逐条新增、修改(前后对比)、删除,agent 的说明或历史分析的总结;按钮 **Apply**(开关关闭时为 “Apply and turn on”)、**Discard**。 +- 草案以 `at` 标识(每次写入都不同,至少比上一份晚 1 ms)。Apply 和 Discard 都带上界面显示的那份草案的 `at`;与当前草案不一致或草案已不存在时返回 `status: "stale"`、不写入,界面刷新草案并提示 “The agent proposed a new draft. Review it before applying.”。 +- Apply 可带 `exclude`:不加入的新检查 id(只能是草案 `added` 里的)。用草案替换项目的 `rules`、`guidance`、`maxRounds`、`goal`(草案里有的字段),开启 Auto review,项目 revision +1,清除草案,关闭项目设置。 +- 草案的 `baseHash`(提出时规则、guidance、maxRounds、goal 的哈希)与当前不同,说明规则在草案之后改过:Apply 先返回 `status: "confirm"`,界面提示“应用会覆盖这些改动”,确认后才写入。 +- Discard 只清除草案。应用或丢弃都不通知 agent。 + +### 6.5 手动编辑 + +项目设置的检查列表(§9.3)可以直接加、改、删、暂停,随 Save 保存,不经草案。 + +## 7. Insights + +### 7.1 返工提示(D14) + +- 条件全部满足时,Insights 页顶部显示返工提示:项目没开 Auto review;近 14 天至少 3 张卡交付后返工(§6.2 的定义);30 天内没点过 Not now。数字和卡片由 daemon 在读 Insights 时算出,不依赖 Insights 的模型运行或开关。 +- 内容: + + ``` + 最近 14 天交付的 12 张卡里,有 5 张交付后你又让 agent 改了 + #a1b2 Add link expiry「新文案没进所有语言」 #c3d4 … + Auto review 可以在交付前先把这类问题查出来,没过就自动退回。 + [Set up auto review] [Not now] + ``` + + 每张卡附返工那一轮项目记忆采集的用户原话(有时,≤ 60 字),最多 3 张。 +- Set up auto review → §6.2。Not now → 记下时间,30 天内不再显示。 +- 一次性提示 `review-suggest`:项目有返工提示且没打开 Insights 时,指向 Insights(§9.6)。 + +### 7.2 检查建议 + +在 Insights 的定期运行里(与 Skills 同一次模型调用,开关、周期、模型、替换规则都沿用 INSIGHTS-REDESIGN-SPEC),项目开启了 Auto review 时加一类建议 `review_rule`: + +- 素材加一节 “Auto review”:当前规则(名称、命令或 prompt 首行);窗口内各条检查的评审次数与失败次数;交付后返工的卡与那一轮用户的原话;返工消息里反复出现的失败。 +- 指令:只为已有规则没覆盖、且在至少 2 张卡上让用户返工或让评审失败的原因提检查;说过(AGENTS.md、记忆、skill)仍然反复出错的优先;最多 3 条。 +- 每条建议带一条规则(按 §8.1 校验)和依据的卡片;与现有规则(名称或 prompt 相似)、30 天内 dismissed 的建议重复的丢弃。 +- 界面在 Insights 的 “Auto review” 区块: + + ``` + 4 张卡交付后,你都要求补全多语言词条 + #a1b2 #c3d4 #e5f6 #0a9b ·「新文案也要进所有语言」 + 检查:每个新加的界面文案都出现在所有语言目录里 + [Add check] [Dismiss] + ``` + + 命令类建议显示完整命令。**Add check** 把这条规则加进项目(id 冲突时加序号,项目 revision +1),建议标为 accepted。同类检查已满 10 条时不写入,返回 “This project already has 10 LLM checks. Remove one first.”(命令类同理),建议保持 open。 +- **暂停建议**(确定性,不经模型):一条未暂停的 LLM 检查(不含内置检查)近 30 天被判定 ≥ 20 次且从没失败 → “ passed all 20 reviews. Pause it to save tokens?” [Pause] [Dismiss]。在项目每次 Insights 运行成功后计算;同一条检查已有 open、accepted 或 30 天内 dismissed 的暂停建议时不再提。 + +## 8. 数据 + +### 8.1 项目 + +``` +project.autoReview?: { + enabled: boolean, // 默认 false + maxRounds: int 0–5, // 默认 2 + goal?: boolean, // 内置检查,缺省为开 + rules: Rule[], // 命令检查 ≤ 10,LLM 检查 ≤ 10 + guidance?: string, // ≤ 4000,只给评审 session 看的项目背景 +} +Rule = { + id: string, // 1–40 位 [a-z0-9-],项目内唯一,不能是 "goal" + name: string, // ≤ 80 + types?: TaskType[], // 只对这些类型的卡生效;不填为全部(D10) + off?: true, // 暂停 +} & ( { kind: "command", argv: string[], timeoutSec?: 10–3600 } // argv ≤ 30 项,每项 ≤ 500 + | { kind: "llm", prompt: string } ) // ≤ 2000 + +project.reviewDraft?: { + rules: Rule[], guidance?, maxRounds?, goal?, + note: string, // ≤ 1000;agent 的说明 + by: { taskId, agentId } | { analysis: true }, + added?: [{ id, cards: string[], why }], // 历史分析新加的检查 + summary?: { delivered, reworked, reasons: [{ title, cards, quote? }], dropped? }, + at, baseRevision, baseHash, +} +``` + +- 草案写入不改项目 revision;`autoReview` 的修改(Save、Apply、Add check)改项目 revision。 +- **迁移**:只有 P1 `checks` 的项目读成命令检查(`id` 由名称生成,重名加序号),下一次保存时改写成 `rules`;无效的旧数据保留,不重置。 + +### 8.2 卡片 + +- `task.reviewSetup?: true`:设置卡片。 +- `task.review`:最近一次评审,新的覆盖旧的;写入不改 task revision。 + +``` +Review { + trigger: { eventId, agentId, from }, // 人按 Rerun 时 eventId 为空 + round: int ≥ 1, maxRounds: int 0–5, + state: "running" | "passed" | "rework" | "failed" | "unavailable" | "cancelled", + reason?: string, // ≤ 500 + fingerprint: { taskRevision, workpadRevision, sha?, dirty }, + shared: boolean, // 非 worktree + rules: [{ id, kind: "command" | "llm", name, + state: "queued" | "running" | "passed" | "failed" | "na" | "skipped" | "unavailable", + ms?, argv?, exitCode?, timedOut?, tail?, // tail ≤ 4 KiB,只在 failed/unavailable 时 + detail?, file?, line?, // 判定理由,passed 时也保留 + reason? }], // skipped/unavailable 的原因 + model?: string, + startedAt, finishedAt? +} +``` + +- 开始时所有检查为 `queued`,命令逐条变 `running` 再变结果;判断类检查在评审 session 运行期间一起为 `running`,结果一起写回。内置检查的条目 `id: "goal"`、`kind: "llm"`、`name: "Does what you asked"`。 +- 插件启动时仍为 `running` 的评审改为 `unavailable`(“Flowly restarted during the review.”)。 + +### 8.3 事件 + +`review_started`;`review_finished`(note 写结果摘要,另带 `results: [{ id, state }]` 供 Insights 统计);`status_changed` + `rule: "R1"` / `"R2"`;`review_rules_proposed`(agent 提交草案)。活动时间线显示 “Auto review” 标签。 + +### 8.4 Insights + +`workflow-insights.json`:`findings[].target` 加 `review_rule`,带 `rule` 字段;暂停建议同样作为 finding 存(`target: "review_pause"`,`ruleId`,`title` 为规则名,`reviews` 为判定次数);新增 `reviewNudges: { [projectId]: { dismissedAt } }`。`review_rule` 与 `review_pause` 自成一类,Insights 新一次运行只替换这一类里仍为 open 的建议,不影响技能草案和其他建议。 + +### 8.5 RPC + +| 名称 | 输入 | 输出 | +| --- | --- | --- | +| `reviews.rerun` | `{ taskId }` | `{ ok }`;卡片须在 In review、项目开启且有生效的检查、没有评审在跑 | +| `reviews.detect-commands` | `{ projectId }` | `{ commands: [{ name, argv, checked }] }` | +| `reviews.analyze-history` | `{ projectId }` | `{ status: "started" \| "running" \| "insufficient" }` | +| `reviews.analysis-status` | `{ projectId }` | `{ state: "idle" \| "running" \| "failed", error?, startedAt?, eligible }`(只在内存里,daemon 重启后为 idle;`eligible`:近 90 天 ≥ 5 次 agent 交付,决定是否显示 Suggest checks from past cards) | +| `reviews.apply-draft` | `{ projectId, at, confirm?, exclude?, add? }`(`add`:勾选的识别命令 `[{ name, argv }]`,作为命令检查加入;已有同样命令或超出上限的跳过) | `{ status: "applied", revision } \| { status: "confirm" } \| { status: "stale" }` | +| `reviews.discard-draft` | `{ projectId, at }` | `{ status: "discarded" \| "stale" }` | +| `reviews.setup-card` | `{ projectId }` | `{ taskId, created, sessionError? }` | +| `insights.add-review-check` | `{ findingId }` | `{ revision }`;建议须为 open | +| `insights.pause-review-check` | `{ findingId }` | `{ revision }` | +| `insights.dismiss-review-nudge` | `{ projectId }` | `{ ok }` | + +## 9. 界面与提醒 + +### 9.1 卡面 + +卡片有评审记录且仍在这一轮里时: + +- `Reviewing n/N`(In review,进行中):卡片底部展开指示条(图标 + 文字 + 运行中卡片同款点阵扫光,跟随 Reduce Motion 和扫光开关),开始时展开、结束时收起;n 为已结束的检查数。 +- `Review passed`(In review)、`Reworking 1/2`(退回后的工作阶段)、`Review failed`(Blocked,或 Blocked 泳道关闭时的 In review)、`Review unavailable`(In review,或 R1 之后投递失败时的工作阶段)、`Review cancelled`(In review)。 +- 卡片的无障碍标签带上评审状态。 + +### 9.2 抽屉(D17) + +- (2026-10-28 删除:原“项目开启 Auto review 时 Auto review 区块放在 Workpad 之前、Workpad 默认收起”。评审现在是 Workpad 里的 **Review** 组,Workpad 按用户的抽屉默认展开,见 GUARDRAILS-SPEC §7.1。) +- Auto review 区块:分 **Commands** 和 **LLM review** 两组(内置检查在 LLM review 组第一行),每组标题右侧 “通过数/条数”;每行状态图标、名称、耗时。`failed` 行展开显示命令输出末尾或判定理由与文件位置;`passed` 的判断类检查点开可看理由;`skipped`、`unavailable` 行显示原因;共享工作区时注明。评审中逐条更新(沿用卡片详情的 etag 读取,不新增轮询)。 +- 卡片在 In review、项目开启且有生效的检查、没有评审在跑时有 **Rerun**(round 重置为 1,返工发给最近一次交付的 session)。 +- 设置卡片的抽屉显示项目草案(§6.4)。 + +### 9.3 项目设置(Edit project → Auto review) + +``` +Auto review [开] + Every delivery runs an LLM review and uses tokens. + <草案块,有草案时> + 检查 + ✦ Does what you asked [暂停] + ▶ Typecheck npm run typecheck + ▶ Unit tests npm test + ◆ 新文案进所有语言目录 · feature, bugfix + [Add check] [Suggest checks from past cards] [Adjust with AI] + Review guidance (有判断类检查时) + Max rework rounds 2 +``` + +- 开关关闭时只显示开关、草案块(有时)和 Adjust with AI;打开时首次进入 §6.1 的识别流程。 +- 成本那一行只在内置检查或 LLM 检查开着时显示(成本信息)。 +- 每行:名称;副行在有内容时显示命令原文或 prompt 首行与类型过滤;暂停的淡显。点行编辑:名称、种类、命令(一行,按空格切分,引号内空格保留,不是 shell)或 prompt(多行)、类型(多选)、暂停、删除。内置检查只能暂停。 +- Suggest checks from past cards 只在开关打开、且项目近 90 天 ≥ 5 次 agent 交付时显示(开关关闭时从 What's new 或 Insights 进入)。分析失败后同一按钮显示为 Retry。 + +### 9.4 Attention + +- 新原因:review failed、review unavailable(只在卡片还停在该评审留下的位置时)。信号 key `review::`,供 Dismiss 判断“有新信号”(ATTENTION-DISMISS-SPEC §2.2)。Review failed 与提问、权限一样排在前面。 +- 通过不是单独的原因:评审期间压住的未读在通过后放开,Mark as read 照常清掉。 + +### 9.5 Insights 页 + +返工提示(§7.1)在页面顶部;检查建议与暂停建议(§7.2)在 “Auto review” 区块,位置在 Skills 之后、其它建议之前;没有建议时整块不显示。 + +### 9.6 引导、What's new、遥测、i18n + +- **新用户**(D14):导览 More features 一行被动介绍,不带按钮:“Auto review: deliveries are checked before they reach you, and go back to the agent when they fall short.” 一次性提示:`auto-review`(项目里有卡片显示评审状态且没打开卡片时,排在 next-move 之后、first-workpad 之前);`review-suggest`(§7.1,排在 `first-skill-draft` 之前)。`tipIds`、`tipText`、`nextTip`、onboarding-logic 测试与 ONBOARDING-SPEC 提示表一起改。 +- **老用户**(D15):What's new 1.8.0 一条:“Auto review: deliveries are checked before they reach you, and go back to the agent when they fall short. Flowly can suggest the checks from your past cards.” 按钮 **Suggest checks from past cards**(新按钮动作:打开当前项目的 Edit project 并开始 §6.2)。 +- **遥测**:`f_auto_review`(有未归档项目开启了 Auto review);`f_auto_review_llm`(其中有开着的内置检查或未暂停的 LLM 检查)。TELEMETRY-SPEC §4.2 与 key-set 测试同步改定义;不加新字段。 +- **i18n**:新文案进所有目录;规则名、prompt、guidance、草案说明、分析总结是用户或模型写的内容,不翻译;内置检查名在渲染时翻译,存储为英文。 + +## 10. 与产品约束的对照 + +| 约束 / 已有决定 | 处理 | +| --- | --- | +| “Automatic assignment, multi-agent handoffs, role orchestration… are outside v1” | 用户 2026-10-27 批准例外(D1、D2),已写进 AGENTS.md Product invariants | +| “ordinary follow-up sends exactly the user's text” | 返工消息是带固定标识的自动验收投递,不是普通 follow-up(D2) | +| project memory `8424d1ab`:否决服务端自动给会话发补登消息 | 那条针对补救 agent 漏做的流程步骤;返工消息是质量验收的结果,用户确认允许(D2) | +| “A turn ending never automatically completes the card.” | 只由交付事件触发;通过不进 Done;R1/R2 只移到工作阶段或 Blocked | +| 临时 session 硬删除、Flowly background workspace 的例外 | 评审 session 已在 Auto review 例外里;**历史分析 session(§6.2)需加入 AGENTS.md 里 background workspace 与硬删除两条例外的名单**(实现 P3 时一起改) | +| “Use subprocess argument arrays, never shell interpolation” | 命令检查只接受 argv | +| “Do not expose tokens … in logs” | 命令输出只存在评审记录里(截断),不进日志 | +| UI 文案规则 | 只保留成本、状态和不明显的后果;规则名等由用户内容填充 | +| Onboarding upkeep | 导览一行、两个提示、What's new 一条(§9.6) | + +## 11. 决定 + +| # | 问题 | 决定 | +| --- | --- | --- | +| D1 | 是否允许 Flowly 自动创建评审 session | 允许(2026-10-27) | +| D2 | 不通过时是否自动给原 session 发返工消息 | 自动发;上限在项目设置里改;达到上限仍不通过移到 Blocked 并进入 Attention(2026-10-27) | +| D3 | L1 的 fail/stale 当场拒绝还是交付后打回 | 当场拒绝(2026-10-27) | +| D4 | 评审期间是否清除 Paseo 原生提醒 | 不处理;在卡片上标注评审状态(2026-10-27) | +| D5 | 退回到哪个阶段 | 交付前所在的工作阶段(2026-10-27) | +| D6 | 共享工作区是否在临时 worktree 里跑命令 | 不隔离(2026-10-27) | +| D7 | 与 LLM 对话配置规则的会话放在哪 | 专用设置卡片,作为次要入口 Adjust with AI(2026-10-28)。否决:项目设置里的独立聊天面板 | +| D8 | agent 或分析提出的规则怎么生效 | 草案 + 用户 Apply(2026-10-28)。否决:agent 确认后直接写入(之后每次交付都会自动执行这些命令) | +| D9 | 命令检查有失败时是否还跑评审 session | 跳过(2026-10-28) | +| D10 | 规则能否按卡片类型限定 | 可选的类型过滤,不填为全部(2026-10-28) | +| D11 | 卡片级的要求怎么表达 | 不设卡片规则;评审读用户在卡片会话里说的话(2026-10-28)。否决:卡片追加规则 + 草案 + Apply(概念多,用户不知道怎么用);评审等级 Thorough、按路径触发(先不做) | +| D12 | 评审的尺子 | 内置检查 “Does what you asked”:卡片标题、描述和用户原话;workpad 是 agent 自报,只作参考(2026-10-28)。否决:默认规则 “Workpad acceptance met”(考生自己出题) | +| D13 | 打开开关时怎么给出命令 | 从仓库确定性识别,用户勾选确认,不用 LLM(2026-10-28) | +| D14 | 新用户怎么接触 | 不主动推;导览一句被动介绍;交付后返工多时 Insights 提示(2026-10-28) | +| D15 | 老用户怎么接触 | What's new 1.8.0 带按钮,用过往卡片生成门禁总结和预选检查(2026-10-28) | +| D16 | AI 怎么推荐规则 | 一个历史分析:升级按钮、设置按钮、返工提示进入同一分析;Insights 定期运行推荐增量检查(2026-10-28) | +| D17 | 开启后用户读什么 | 抽屉以评审结果为主,Workpad 默认收起(2026-10-28) | + +## 12. 分期 + +1. **P1(已实现)**:L1、命令检查、评审记录、卡面状态与 Attention、R1/R2、返工投递、项目设置。 +2. **P2(已实现)**:规则列表(命令 + LLM、类型过滤、暂停)、设置卡片与草案(`at` 核对、`baseHash` 确认)、评审 session、Review 模型、逐条结果、`get_task.reviewRules`。 +3. **P3(未实现)**: + 1. 内置检查与用户原话输入(§4.2、§4.4),设置说明去掉 “Workpad acceptance met”; + 2. 命令识别与 Use these(§6.1),设置界面按 §9.3 调整,Set up with AI 改名 Adjust with AI; + 3. 抽屉以评审结果为主(§9.2); + 4. 历史分析与门禁总结(§6.2),草案扩展(§8.1),What's new 按钮(§9.6); + 5. Insights 返工提示、检查建议、暂停建议(§7),`review_finished.results`; + 6. 引导与提示(§9.6),AGENTS.md 例外名单(§10)。 + +## 13. 验收 + +### 13.1 P1、P2(回归) + +- 项目开启后,agent 交付时 workpad 有 fail 或 stale → `WORKPAD_FAILING`,带 `acknowledgeOpen=true` 也拒绝,卡片不动;项目关闭时与以前一致。 +- agent 交付 → 该 session 这一轮结束后开始评审,期间该卡不因未读进入 Attention;通过后显示 Review passed,未读照常进入 Attention。 +- 从 Test 交付、一条命令退出码非 0 → 回到 Test,Reworking 1/N,原 session 收到带标识的返工消息,`get_task.review` 有意见;A1 没有把卡改到 Development。 +- 已返工 `maxRounds` 次后再次不通过 → Blocked、Review failed、Attention,不再发消息;`maxRounds = 0` 时第一次就进 Blocked;Blocked 泳道关闭时留在 In review。 +- 评审进行中人拖动卡片、发消息或有新提交 → 结果丢弃;仍在 In review 时显示 Review cancelled,可 Rerun。 +- 命令不存在、超时、评审 session 失败、原 session 已归档 → Review unavailable,不计返工次数。 +- 三条命令中第二条失败:三条都跑完(passed、failed、passed),判断类检查全部 skipped,按 R1 退回。 +- 评审 session 输出缺一条 → 那条 unavailable;其余都通过 → Review unavailable。 +- 评审 session 结束后被硬删除。人拖进 In review 不触发;设置卡片交付不触发。 +- 草案:不合法 → `REVIEW_RULES_INVALID`;Apply 替换规则、项目 revision +1、开启 Auto review;看着草案 A 时又来了 B → Apply/Discard 返回 stale;规则在草案之后改过 → 先 confirm。 +- `types: ["bugfix"]` 的检查在 feature 卡上不跑;`off` 的不跑。 +- P1 的 `checks` 读成命令检查,保存后写成 `rules`。 + +### 13.2 P3 + +- 项目开启 Auto review、没有其他检查:交付后只跑内置检查;评审 session 的输入里有卡片标题、描述和用户在会话里的消息(不含返工消息),workpad 标为 agent 自报。 +- 用户在会话里先要求 A、后说“A 先不做”:改动没做 A 时内置检查不因 A 判 fail。 +- 内置检查暂停(`goal: false`)且没有其他检查:交付不启动评审。 +- 打开开关:TS 项目(有 `pnpm-lock.yaml` 和 typecheck、lint、test、build、test:watch 脚本)识别出 `pnpm run typecheck`、`pnpm run lint`、`pnpm run test`(勾选)和 `pnpm run build`(不勾选),不含 test:watch;Use these 后随 Save 写入;识别不执行任何命令。 +- 近 90 天交付不足 5 次:Suggest checks from past cards 不显示;从 What's new 进入时显示 “Not enough past deliveries…” 和识别结果,不调用模型。 +- 历史分析:交付后返工的统计排除被 A1r 还原的回合;结果写成 `by: { analysis: true }` 的草案,`summary` 的数字等于 daemon 统计;少于 2 张卡依据的检查被丢弃;取消勾选一条后 Apply,它不在规则里;分析 session 被硬删除,background workspace 在用完后归档。 +- Insights:项目关闭 Auto review、近 14 天 3 张卡交付后返工 → 顶部显示返工提示;Not now 后 30 天不显示;Set up auto review 进入历史分析。项目开启时,定期运行产出 `review_rule` 建议,Add check 后规则加入、项目 revision +1、建议 accepted;与现有规则重复的建议被丢弃。 +- 一条 LLM 检查近 30 天 20 次全通过 → 出现暂停建议;内置检查不出现暂停建议。 +- 抽屉:项目开启时 Auto review 区块在 Workpad 之前、Workpad 收起;项目关闭时 Workpad 展开。 +- What's new 1.8.0 的按钮打开当前项目的 Edit project 并开始分析;新用户不看到 What's new,导览只有被动介绍;`review-suggest` 提示只在有返工提示时出现一次。 +- `f_auto_review_llm` 在只开内置检查时为 true。 diff --git a/docs/CARD-SETUP-SPEC.md b/docs/CARD-SETUP-SPEC.md index 43c1d46c..0f510062 100644 --- a/docs/CARD-SETUP-SPEC.md +++ b/docs/CARD-SETUP-SPEC.md @@ -1,5 +1,7 @@ # 卡片开工设置:type / tags / workpad 的提醒与软卡(CARD-SETUP-SPEC) +> 2026-10-28:本文部分内容被 [GUARDRAILS-SPEC](GUARDRAILS-SPEC.md) 取代(§12)。被取代:“workpad 缺失”改为“acceptance 缺失”;其余仍有效。 + 2026-10-22:Start of every turn 改为共享六项动作清单,实时 get_task 返回相同的 `turnChecklist`;仍先补 setup.missing,小卡不例外。现有缺项校验、提醒和权限不变。见 [TURN-CHECKLIST-SPEC](TURN-CHECKLIST-SPEC.md)。 状态:2026-10-21 用户审阅通过并要求实现;已实现,Luna 6 验证通过([报告](verification/card-setup-2026-10-21.md):Sonnet 5.5 medium 与 Luna 都在首次改文件前补齐了三项;真实 session 的 `SETUP_INCOMPLETE` 拒绝未触发,仅单测覆盖)。卡片 `ee717828`(“Flowly 流程约束强化”,“发布v1.5.0”的跟进)。 diff --git a/docs/DELIVERY-CONSTRAINTS-SPEC.md b/docs/DELIVERY-CONSTRAINTS-SPEC.md index 523ecf9e..c2dd7fdd 100644 --- a/docs/DELIVERY-CONSTRAINTS-SPEC.md +++ b/docs/DELIVERY-CONSTRAINTS-SPEC.md @@ -1,5 +1,7 @@ # 交付产物与先拒后确认(2026-10-22) +> 2026-10-28:本文部分内容被 [GUARDRAILS-SPEC](GUARDRAILS-SPEC.md) 取代(§12)。被取代:§2 的 `WORKPAD_OPEN` 部分;`SETUP_INCOMPLETE` 的先拒后放与 outputs 仍有效。 + 范围:接续“流程约束不足”卡片,实施调查报告建议 2。规则由插件提供,适用于所有项目、provider 和模型,不依赖项目 AGENTS.md。先完成本规范,再实现。 ## 1. 交付调用的 outputs diff --git a/docs/DRAWER-SPEC.md b/docs/DRAWER-SPEC.md index dbe36aa2..7e9cd869 100644 --- a/docs/DRAWER-SPEC.md +++ b/docs/DRAWER-SPEC.md @@ -37,10 +37,12 @@ Actual desktop/compact appearance, dropdown placement, path wrapping and gesture Status: implemented 2026-10-19. Card: UI/UX 调整. Title kept for links; covers the whole sidebar layout. +2026-10-28 Workpad and Auto review (GUARDRAILS-SPEC §7.1): the separate Auto review section and the Memory section are gone. The Workpad section holds four groups, each shown only with content: Acceptance, This card (what the card was sent back for, memory it left), Review (the latest review, failed items first, with Rerun) and Plan. It opens per the user's sidebar defaults; the old "Workpad collapsed while Auto review is on" rule is removed. + 1. **One section pattern.** Every top-level section of the card sidebar (Progress, Sessions, Workpad, Artifacts, Ideas, Links, Details & workspace, Usage, Activity) is a `Section` (`client/disclosure.tsx`): a header whose title (and the chevron after its count) collapses or expands it, actions on the right, and the body revealed with the shared measured-height `Collapse`. While collapsed the header shows one muted line of what is inside: Progress the latest state (or *Updating…*), Workpad `Acceptance n/m · Checks n/m` (passed checks), Sessions `n running`, Details the workspace name, Usage the token summary. A section with nothing in it (Artifacts or Links at 0) shows a plain header with its actions and no chevron. Nested lists (Progress → History, archived sessions, closed ideas) keep the smaller `Disclosure` with a leading chevron. 2. **Layout settings (Settings → Cards → Card sidebar).** One list: the six movable sections in their order (drag the grip on desktop, up/down arrows on touch), a divider, then Details & workspace, Usage and Activity (pinned at the bottom: they mount after the open transition, so nothing above them moves). Every row has a *Starts open* switch. *Reset sidebar layout* restores both. Host-scoped `board-view` values: `drawerOrder: string[]` (≤ 20 ids, each ≤ 40 chars) and `drawerOpen: Record` (only choices that differ from the default are stored). Defaults (user decision 2026-10-19): order Progress, Sessions, Workpad, Artifacts, Ideas, Links; Links, Details, Usage and Activity start closed, the rest open. `shared/drawer-sections.ts` resolves both: known ids in their stored order (duplicates dropped), a missing id goes after the section that precedes it by default, unknown ids are kept in storage and ignored. A section that is off (Workpad when the project turns it off, Ideas when Ideas is off or the card has none) is skipped in place. 3. **Open state per opening.** The drawer owns which sections are open. Each time the sidebar opens (or the defaults change) it starts from the user's defaults; toggles last until it closes. -4. **Long lists page by 5.** Artifacts (per role) and the Workpad's Acceptance, Checks and Confusions show 5 rows, then *Show N more* (N = the next batch, at most 5) adds 5 more to the same list at the rows' left edge. No *Show less*; closing and reopening the sidebar, or another card, starts over. Each added page opens like a section (measured height and fade, `PagedRows` in `client/disclosure.tsx`). Checks are listed failed, out of date, not run, then passed (stable within each), so what needs a look is on the first page. A check's command shows one line until its output is shown or the command is tapped. +4. **Long lists page by 5.** Artifacts (per role) and the Workpad's Acceptance list show 5 rows, then *Show N more* (N = the next batch, at most 5) adds 5 more to the same list at the rows' left edge. No *Show less*; closing and reopening the sidebar, or another card, starts over. Each added page opens like a section (measured height and fade, `PagedRows` in `client/disclosure.tsx`). Checks are listed failed, out of date, not run, then passed (stable within each), so what needs a look is on the first page. A check's command shows one line until its output is shown or the command is tapped. 5. **Phones.** On compact layouts only the action row (Back, project, Edit, More) stays fixed; the title, Status/Priority/Type and tags scroll with the sections, so the content gets the screen. 6. A `Collapse` mounted open stays in normal layout until it is measured, so an expanded section does not flash at zero height. Progress → History rows start at the section's left edge (`Disclosure` `flush`) and an opened version's fields line up with its title. Each version shows a compact time (`shared/compact-time.ts`): today the time only, this year month, day and time, earlier years the date with its year; no seconds. @@ -54,7 +56,7 @@ Status: implemented 2026-10-27. Card: 搜索功能 (SEARCH-SPEC §5.3). 4. When the target is laid out the drawer scrolls it near the top of the sidebar's scroll area (`measureLayout` against the scroll content) and rings it with the accent `CueRing` for about 1.6 s. With Reduce Motion the scroll is not animated. If the target has not rendered 1.5 s after the request (removed item, section off), the section itself is the target. 5. Toggling any section, scrolling or closing the sidebar never re-applies an old request. 6. **Highlight.** The request also carries the hit's field and its matched words (the folded text under the snippet's ranges, `termsOfHit` in `shared/search-text.ts`; `#tag` filters are never highlighted). Only the target item marks those words: the words themselves get an accent highlight background and bold accent text (`matchRanges`, the same folding as the daemon). The marks stay until the sidebar closes, another card opens or a new hit is clicked. A target with marked words gets no ring around the item (the words are the cue); a target without words (a card-field or tag-only hit, or the section fallback) keeps the 1.6 s ring. A section-level target (a summary hit) marks the Progress card. -7. **What the target shows while marked.** So a match is never cut off: an Activity note drops its 4-line limit; a check shows its whole command and its output; the Progress card and an idea card open past *Show more*, and Progress shows the summary's Target even where the workpad normally replaces it (when the hit is in Target); an artifact row shows its title and note unclipped plus its full link or path; a closed idea shows the matched field under its title. Nothing else in the sidebar changes, and no stored setting is written. +7. **What the target shows while marked.** So a match is never cut off: an Activity note drops its 4-line limit; the Progress card and an idea card open past *Show more*, and Progress shows the summary's Target even where the workpad normally replaces it (when the hit is in Target); an artifact row shows its title and note unclipped plus its full link or path; a closed idea shows the matched field under its title. Nothing else in the sidebar changes, and no stored setting is written. ## Type scale @@ -64,7 +66,7 @@ Status: implemented 2026-10-19. Card: UI/UX 调整. One scale for every sidebar | --- | --- | --- | | Card title | `xl` 18 bold | task title | | Description, session titles, idea headlines | `base` 14 | | -| Section titles; body text of lists and cards | `md` 13 | Progress text, Workpad criteria/checks/plan/confusions, artifact and link titles, History versions, Details values, Usage values | +| Section titles; body text of lists and cards | `md` 13 | Progress text, Workpad acceptance/review/plan, artifact and link titles, History versions, Details values, Usage values | | Secondary lines | `sm` 12 | content-card footnotes (`Updated … · model`) and meta rows (Ideas), artifact notes, check commands and run info, `Updated … by …`, environment stamp, link lane, History dates, Details labels, empty states, Show more | | Group labels and field titles | `xs` 11 bold/muted | Acceptance, Checks, Outputs, Inputs, Target, Recap | diff --git a/docs/GUARDRAILS-SPEC.md b/docs/GUARDRAILS-SPEC.md new file mode 100644 index 00000000..1a0d2325 --- /dev/null +++ b/docs/GUARDRAILS-SPEC.md @@ -0,0 +1,613 @@ +# 项目护栏:学、防、查、看的闭环(GUARDRAILS-SPEC) + +状态:**设计,用户 2026-10-28 批准,待实现**。卡片 `2458cff4`(重新设计 memory、insights、auto review)。本文把项目记忆(PROJECT-MEMORY-SPEC)、卡片 Workpad(WORKPAD-SPEC)、交付自动验收(AUTO-REVIEW-SPEC)和 Insights 的建议部分(INSIGHTS-REDESIGN-SPEC、WORKFLOW-INSIGHTS-SPEC)改成一条线。被取代的小节见 §12。 + +前提:Auto Review 卡(`4286f00a`)的 `feat/auto-review`(`5cbb9bc`,P1–P3)是本文的基础;实现分支 `feat/guardrails` 从它分出,最终一个 PR 进 main 同时带上两者(§13)。P4 不再单独做。 + +## 0. 用户的决定(2026-10-28) + +| # | 问题 | 决定 | +| --- | --- | --- | +| D1 | Auto review 对新项目默认开 | 开。老项目升级时不自动开,What's new 给按钮。推翻 AGENTS.md 里 2026-10-27 例外的 “Off by default” 与 AUTO-REVIEW-SPEC D14 | +| D2 | 记忆写入即生效,允许自动退休 | 是。推翻 PROJECT-MEMORY-SPEC “人确认才生效”“active 从不自动淘汰”(§1.2、§5.2、§6.5) | +| D3 | workpad 只留验收清单,agent 不再自报 pass/fail | 是。推翻 WORKPAD-SPEC §2.1 的验证项、§5 的新鲜度与软门槛、AUTO-REVIEW-SPEC §4.1 的 L1 | +| D4 | 纠正第一次只管本卡,第二张卡再出现才升项目准则 | 是 | +| D5 | 与 Auto Review 卡衔接 | 先合入 5cbb9bc,本卡在其上实现 | +| D6 | 准则范围 | 跟着证据走:范围等于它来自的卡的形状之并,只扩不缩(§4.3) | +| D7 | 淘汰 | 只用退卡、不退、移到 Done 三个信号自动做,没有“不算问题”按钮,删除永远不是必需的(§6.6) | +| D8 | 命令检查的更新 | 每周重识别加使用证据,新命令先观察 3 次评审(§4.5) | +| D9 | Insights 与 workpad | 一份账两个视图:workpad 是卡的账,Insights 是项目的账(§7) | +| D10(2026-10-29 试用后) | Memory 保留原名和独立入口,不并入 Guardrails 页 | 是。备忘和护栏关联不大,强行合并不合理;数据仍存在 guardrails.json(kind `note`),只是入口、页面和文案分开。Guardrails 页只剩 Commands、Guidelines | + +已有的决定仍然有效:AUTO-REVIEW-SPEC D1–D6、D9、D12(评审的尺子是用户的话,不是 agent 的自报)、D17。 + +### 0.1 实现者检查后的补充决定(2026-10-28,本文作者的判断,可推翻) + +一个没参与讨论的 session 以实现者身份读了本文,提出 7 个会影响实现的问题。都不需要用户拍板,这里记下答案,正文已按此改写: + +| # | 问题 | 决定 | +| --- | --- | --- | +| B1 | `fail` 必须引用 diff 里的文件,那“漏做了”和“某个目录缺词条”永远判不出来 | 证据分三种,任一即可:diff 里的文件;仓库里存在的文件(缺漏发生的地方);卡片或用户原话的引用(没做到的要求)。完全没有证据的 `fail` 才降为 `na`(§6.3) | +| B2 | 归纳输入只给范围内的 active 准则,范围扩大和复活没法发生 | 输入列出本项目全部未删除的准则(含范围外、暂停、归档),标状态与范围;60 行上限按优先级截断(§4.2) | +| B3 | 新项目默认开,命令在哪一步勾选;导览开关要存在哪 | 不再勾选:项目创建或开关打开时识别到的命令直接以 `observing` 加入,表单只显示识别结果,不满意去 Guardrails 页删。导览第 2 页不加开关,只有一句介绍和成本;不新增 host 设置(§4.5、§8、§10) | +| B4 | 项目级 Project.memory 开关去留;host 的 Guardrails 关闭后准则还参不参与评审 | 项目开关保留,改名 Guardrails;host 或项目任一关闭时:不学、不注入、不复盘,准则与专属项不参与评审,命令和内置检查照跑(§6.3、§8) | +| B5 | 迁移放 P1 还是 P2;各期之间会不会发版 | 三期合在 1.8.0 一起发,迁移只有一次;P1 不再声称可独立发布(§3.6、§13) | +| B6 | 返工回合只认 A1,关了自动状态的项目就不学 | 改为与记忆线索 `feedback` 同一事实:回合由用户自己的消息开始,且开始时卡片在 In review;不依赖 A1/A1r,归纳本身负责把“提交并推送”判为 `none`(§4.1) | +| B7 | 争议怎么跨轮保存 | 存在 `task.disputes`,随卡保留到 Done/Cancelled;之后各轮该项直接记 `disputed`,不再引用、不再判 fail(§6.1、§6.6) | + +### 0.2 实现中记下的偏差(阶段 A,2026-10-28,已接受) + +- `counts.usedBy` 存 `{ taskId, at }[]`(一小时节流和“被几张卡用过”都要时间);另有 `lastMissedAt`、`misfireAt[]`(最近 10 条)、`unavailableRun`,§6.7 的三条规则靠它们计算。 +- 备忘保留旧的 `source { taskId, agentId, eventId }`,`flowly_remember` 据此幂等;复盘的依据放在 `sources[0].quote`。 +- `flowly_remember` 每卡最多 5 条(`MEMORY_LIMIT`),因为备忘现在写入即生效。 +- pinned 备忘不被挤出、不因 90 天没用归档。 +- 大小未知视为在范围内(同类型未知);升级时范围取已知类型与大小之并,都未知为 `all`。 +- 迁移的 LLM 规则:`text` 取 prompt 前 200 字,更长的全文进 `body`;`off` 的命令规则也迁为 paused。 +- `guardrails.detect` 返回 `{ found, added }`,只加识别时默认勾选的脚本(build、e2e 不加);`guardrails.counts` 返回 `{ observing, added }`,`added` 为近 7 天学到的准则数。 +- 没有 memory.json 也没有任何 autoReview 时不写迁移文件,第一次写入时才出现。迁移失败(memory.json 读不出或不合 schema、看板读失败)时库只读:读返回空,写入抛错并记日志,不创建 guardrails.json,下次 daemon 启动再试;原 memory.json 留在磁盘上(2026-10-29 按 PR #75 的 CodeRabbit 意见改,之前是“按空运行、第一次写入创建文件、不再重试”,那样一次失败就把旧数据永久丢在一边)。 + +### 0.3 实现中记下的偏差(阶段 B、C 与合并,2026-10-28,已接受) + +- 评审记录里没有 `tokens`:SDK 的评审结果不带用量,Workpad 的 Review 组只显示状态、轮次、模型(§6.4、§7.1 中的“tokens”不实现)。 +- 被质疑的内置目标项与卡片专属项没有条目可记 misfire,`insights.guardrails.misfires` 只统计准则与命令的误判;§3.4 “发现新命令但已到上限”的 Insights 提示没有通道,不做。 +- 旧 skill 的会话向 `flowly_update_workpad` 发 `validation`/`confusions` 时返回 `WORKPAD_INVALID` 并说明只收 `acceptance`;旧版 workpad 记录读取时丢掉这些字段,不迁移。 +- `autoReviewRuns(project)` 只看项目开关;旧的 `reviewSetup` 卡片、`reviewDraft`、`memory.*` RPC、`reviews.detect-commands|analyze-history|analysis-status|apply-draft|discard-draft|setup-card`、`insights.add-review-check|pause-review-check|dismiss-review-nudge`、`workpads.resolve-confusion|refresh` 全部删除;存过的 `review_rule`/`review_pause` 建议读取时丢弃。 +- 评审在第 2 轮起用 `git diff <上一轮 sha>` 做增量;上一轮通过且这一轮没给结论的项沿用通过。 +- 新项目与“编辑项目”的 Auto review 只有开关、成本一句和 Max rework rounds;客户端不调用 `guardrails.detect`,由 daemon 在创建项目和保存开关时识别命令(取消表单不留副作用)。 +- What's new 1.8.0 带“Turn on for this project”按钮,打开当前项目的 Auto review 设置;已有项目的 Auto review 保持关闭,新项目默认开、2 轮。 + +## 1. 目的 + +### 1.1 本机数据(Flowly 项目流转日志,2026-10-14 至 10-28) + +| 事实 | 数字 | +| --- | --- | +| agent 交付次数 / 卡片 | 461 / 104 | +| 交付后用户回复的中位时间 | 2.3 分钟 | +| 交付后用户的回复是返工指令(排除被 A1r 还原的回合) | 310 次,67% | +| workpad 被 agent 更新 / 被人勾选 | 736 次 / 7 次;72 份里 1 份被人点过 | +| 最后一次交付带未完成项的 workpad | 45 / 71,多数是过期 pass 和 unrun | +| 验证项自报状态 | 321 pass · 39 unrun · 4 fail | +| workpad 里最常见的命令 | typecheck、npm test、vitest、plugin reload,每张卡都一样 | +| 记忆候选被确认 | agent 提的 12/18,Done 复盘提的 5/14,12 条积压 | +| 采集到的用户原话 | 380 条,只用作复盘输入 | +| Skills 运行 / 建议采纳 | 53 次运行 30 次跳过;13 条建议采纳 2 条 | + +结论:In review 实际是“等你看一眼”,不是验收;workpad 是 agent 的独白,门槛靠它自己确认放行;记忆的瓶颈是收件箱;最值钱的材料是用户退卡时说的话,它现在最没被用上。 + +### 1.2 一句话 + +**你退卡时说的话变成这个项目的护栏;agent 交付时 Flowly 先按护栏查一遍;查过了才叫你;查出的新问题再长成护栏。** 学、防、查、看是同一份数据的四个面。 + +### 1.3 目标、不做、反目标 + +目标: +- 交付先过机器,再到人。人只在通过后、agent 提问、权限请求和到达返工上限时被叫。 +- 护栏从真实的退卡长出来,不需要人写规则、确认候选或清理列表。 +- 库有界:范围跟证据走,退出靠计数,上限靠挤出。 + +不做: +- 人拖进 In review 触发评审;评审 agent 改代码;多评审者投票;CI/PR 状态接入;临时 worktree 隔离(沿用 AUTO-REVIEW-SPEC §1.2)。 +- agent 或模型给评审新增规则。能让评审退卡的准则只从用户的退卡原话长出来。 +- 插件写仓库文件(skill 除外,沿用 INSIGHTS-REDESIGN-SPEC §4)。 +- 向量检索、跨项目护栏。 + +反目标(做到了也算失败): +- 用户需要专门去设置页配置什么才能用上。 +- 列表越用越长、越用越旧,最后没人看。 +- 第一天就因误判把卡退回去,让用户关掉开关。 + +## 2. 用户看到的三样东西 + +| 看到的 | 一句话 | +| --- | --- | +| 卡面 **Checked ✓ / Sent back ↩** | agent 交付后 Flowly 先查一遍。过了才进 Attention;没过直接退给 agent 改。 | +| **Guardrails** 页 | 这个项目查什么:项目自带的检查命令,加上你退卡时说过的话。每条写着抓到几次、误判几次、用在哪类改动上。 | +| **Done 还是你定** | 查过只说明没发现你以前抱怨过的问题,不代表做对了。 | + +用户不需要知道:评审 session、模型回退链、硬删除;退回目标阶段、返工计数、指纹;形状、范围、观察期、三个计数怎么算;候选、草案、确认这几个状态(已经没有了)。 + +整条线里只剩一处要点一下:把 skill 写进仓库(它写进你的仓库)。其余都不需要你点,也不需要你删。 + +## 3. 护栏库(数据) + +### 3.1 文件 + +`guardrails.json`,与 `board.json` 同目录,串行写、写前备份 `.bak`、原子替换,无效时保留文件并报错。取代 `memory.json`(迁移见 §3.6)。不放进 SQLite 看板库:看板按 etag 频繁读取,护栏正文不需要跟着走(同 PROJECT-MEMORY-SPEC §3.1 的理由)。 + +``` +{ version: 1, + entries: Entry[], // 项目护栏:命令、准则、备忘 + items: CardItem[], // 卡片专属项 + clues: Clue[], // 用户原话,沿用 PROJECT-MEMORY-SPEC §4.0 + retros: [{ taskId, at, summaryId?, clueAt? }], + errors: [{ projectId, taskId?, at, message }], + recallStats: { [projectId]: { days: { [day]: { queries, empty } } } } } +``` + +### 3.2 Entry + +``` +Entry { + id, projectId, + kind: "command" | "guideline" | "note", + text: string, // 命令:名称 ≤80;准则:一句可判断的话 ≤200;备忘:标题 ≤120 + body?: string ≤1000, // 备忘正文;准则可选的补充说明 + // command + argv?: string[] (≤30 项,每项 ≤500), timeoutSec?: 10–3600, + observe?: { reviews: int, passed: int }, // 观察期计数(§4.5) + // guideline + scope?: { types: TaskType[] | "all", sizes: Size[] | "all" }, // Size = "s" | "m" | "l"(§6.2) + // note + noteKind?: "decision" | "pitfall" | "contact" | "convention", + tags?: string[] ≤4, keywords?: string[] ≤12, + pinned?: boolean, // 备忘:始终注入,每项目 ≤5 + // 共同 + state: "active" | "observing" | "paused" | "archived", + stateReason?: string ≤200, stateAt, + counts: { reviews, passes, caught, missed, misfire, lastCaughtAt?, usedBy: string[] ≤20, usedAt? }, + sources: [{ taskId, at, quote ≤300 }] ≤10, // 来源卡与原话;备忘的 source 同旧 MemoryEntry.source + by: "user" | "agent" | "retro" | "detect" | "learned", + supersedes?: id, supersededBy?: id, + revision, createdAt, updatedAt } +``` + +- `state` 的含义:`active` 参与评审或注入;`observing` 只有命令有,跑但不退卡;`paused` 不参与,保留计数,可自动复活;`archived` 不参与,保留历史,可恢复。 +- 准则的 `text` 是用户原话的归纳,`sources[].quote` 是原话本身。界面以原话为主显示。 + +### 3.3 CardItem(卡片专属项) + +``` +CardItem { + id, projectId, taskId, + text ≤200, quote ≤300, // 归纳与原话 + shape: { type?: TaskType, size?: Size }, + count: int ≥1, // 本卡为同一件事退了几次 + state: "open" | "promoted" | "closed", + promotedTo?: entryId, + createdAt, updatedAt } +``` + +- 每卡最多 5 条 `open`,超过时关闭最早的(`closed`)。这是 `closed` 唯一的来源。 +- 卡进 Done 或 Cancelled 时项**不关闭**:第一张卡多半在第二张卡遇到同一问题之前就已经 Done,关闭会让升级永远发生不了(实现时发现,2026-10-28)。完成的卡不再评审,所以它的 `open` 项只剩一个用途:§4.2 的“其他卡近 30 天的 open 项”。永久删除卡片时它的项随卡删除(§3.5);`promoted` 的保留作来源。 + +### 3.4 上限 + +| 项 | 上限 | 满了 | +| --- | --- | --- | +| 每项目 `guideline` active | 20 | 新升上来的挤掉抓到率最低、相同时最久没抓到的一条(归档,`stateReason: "displaced"`),Insights 本周变化里记一行 | +| 每项目 `command` active + observing | 10 | 新识别到的不加入,Insights 记一行 | +| 每项目 `note` active | 100 | 挤掉最久没用过的一条(归档) | +| 每项目 pinned 备忘 | 5 | 拒绝 | +| 每卡 `open` CardItem | 5 | 关闭最早的 | +| 每项目 archived | 300 | 删除最早的,写 `removed/` 备份 | + +### 3.5 删除、归档与移除 + +- 永久删除卡片:它的 `open` 和 `closed` 项与线索删除并备份;`promoted` 项保留;它作为 `sources` 的条目保留。 +- Remove 项目:该项目全部条目、项、线索、复盘记录写入 `removed/-guardrails-