Repository navigation
feat(ai): /plan as an editable starter skill + durable conversation↔plan binding - #2359
Conversation
…ersation↔plan binding Adds a planning affordance and, more importantly, the state that makes it survive a long conversation. Two tiers of skill, split by OWNERSHIP not implementation. A built-in skill teaches PageSpace's own mechanics (how a CANVAS page works, what SUBTASKS_INCOMPLETE means) — users have no reason to edit those and a deploy is the right way to change them. A STARTER skill is methodology, where the useful property is that the user can disagree with it and rewrite it. So /plan seeds once as a real DOCUMENT at Home/Skills/Plan plus a personal commands row, and then belongs to the user; the registry is a seed, not a live source. Idempotence rides users.starterSkillsInstalledAt rather than "does a command with this trigger exist?". Only a stamp both refuses to resurrect a starter the user deliberately deleted AND lets the backfill re-run safely after a partial failure. New signups install inside provisionHomeDriveIfNeeded (both drive-creation branches, same tx, web + admin copies); existing users get scripts/backfill-starter-skills.ts, so the auth hot path is untouched. The binding (conversations.planPageId, set by new set_plan/clear_plan tools) renders an ACTIVE PLAN: section into the CACHE-STABLE system prompt. That placement is the requirement, not an optimization: compaction is lossy, so a pointer carried in the message stream is summarized away exactly when it is most needed — the same reason TasksDropdown's message-derived task binding empties on truncation. The system prompt is never compacted. It stays cache-safe because the value changes on explicit tool call and never on navigation: one prefix invalidation per plan change, the trade Agent Memory already makes. Plan page titles are user-authored, so they pass clipDescription before reaching the prompt — an unsanitized title could forge a section. PlanChip sits beside TasksDropdown in both chat headers but reads the persisted binding, so it survives a reload and a summary. Also: buildLocationTurnPrompt was silently dropping currentPage.id even though LocationContext carried it, so the model could only address pages by title. Now rendered (volatile block, zero cache cost), along with the Home drive id in the no-location branch — list_drives exposes neither drive kind nor a Home marker, so the "driveless plans go to Home" rule was otherwise unreachable. The standing "do NOT assume the Home drive" guard is unchanged; the skill body carries the narrow exception. Dead isTaskLinked removed from this prompt only (still live in the page tree and page-read tools). set_plan/clear_plan are deliberately NOT in WRITE_TOOLS: they mutate conversation metadata, not user content, and binding an existing plan doc is a legitimate read-only-mode action. Read-only still strips create_page, so /plan cannot write its artifact there — the body says so and offers to save on write mode rather than thrashing. Gates: monorepo typecheck, build and lint clean; lib 8,742, web 16,142 and admin 178 tests passing against a freshly migrated database. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KebqPHDM6zHpKV7VPt2Fa
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
📝 WalkthroughWalkthroughThis change adds seeded starter skills, transactional Home-drive installation, and existing-user backfill. It also adds persisted conversation plan bindings, plan tools, prompt context, APIs, chat UI, schema changes, tests, and updated tool-count documentation. ChangesStarter Skills and Conversation Plans
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant ChatUI
participant PlanTools
participant Database
participant SystemPrompt
User->>ChatUI: Request planning action
ChatUI->>PlanTools: set_plan
PlanTools->>Database: Validate page and conversation
PlanTools->>Database: Persist planPageId
ChatUI->>SystemPrompt: Build next conversation prompt
SystemPrompt->>Database: Load active plan
Database-->>SystemPrompt: Plan identity and title
SystemPrompt-->>ChatUI: Prompt with ACTIVE PLAN context
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (2)
apps/web/src/lib/ai/core/plan-binding.ts (1)
90-90: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winKeep the stable prompt independent of mutable page titles.
A page rename changes
plan.titlewithout aset_planorclear_plancall. This invalidates the provider cache despite the prompt contract stating that plan changes are the only invalidation source.Render the stable
pageIdonly, or move title rendering to a deliberately volatile section.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/lib/ai/core/plan-binding.ts` at line 90, Update the stable prompt construction around the plan context sentence to remove the mutable plan.title value from the cached content, retaining only the stable plan.pageId; if the title must remain visible, render it in the deliberately volatile prompt section instead.scripts/backfill-starter-skills.ts (1)
69-74: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winThe dry-run count over-reports installs.
The dry-run branch adds
STARTER_SKILL_TRIGGERS.lengthfor every scanned user. It ignores triggers the user already owns, which the real run skips. The dry run therefore reports a higher number than the real run will install.Query the existing
commandstriggers per batch and subtract the collisions, so the dry run predicts the real result. This also lets you populatesummary.skippedCollisionon a dry run.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/backfill-starter-skills.ts` around lines 69 - 74, Update the dry-run path in the batch loop of the backfill script to query each batch’s existing command triggers, count overlaps with STARTER_SKILL_TRIGGERS, and increment summary.installed only for non-colliding triggers. Also increment summary.skippedCollision for those overlaps so dry-run totals match the real run.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/src/app/api/ai/conversations/`[conversationId]/plan/route.ts:
- Around line 60-113: Add direct route tests for the GET and DELETE handlers,
covering ownership isolation, trashed or otherwise inaccessible plans, DELETE
rejection when CSRF validation fails, and successful authorized unbinding that
clears planPageId and returns the expected response. Reuse the route’s existing
authentication, database, and audit behavior in the tests without changing the
handlers’ contracts.
In `@apps/web/src/components/ai/shared/PlanChip.tsx`:
- Around line 38-45: Update the plan-tool success handling in set_plan and
clear_plan to invalidate or revalidate the SWR key used by PlanChip,
`/api/ai/conversations/${conversationId}/plan`, after each mutation succeeds.
Reuse the existing SWR mutation mechanism and ensure both binding and clearing
refresh the chip state; add integration coverage for successful set_plan and
clear_plan flows.
In `@apps/web/src/lib/ai/core/plan-binding.ts`:
- Line 68: Update getActivePlan to accept a principal-aware authorization check
in addition to userId, and use that check for row.pageId instead of relying
solely on canUserViewPage. Ensure the bound plan is returned only when the
authenticated principal, including MCP drive-scoped tokens, is authorized for
the page’s drive.
In `@docs/specs/agent-skills.md`:
- Around line 144-147: The starter description limit is inconsistent between the
specification and test guard. In docs/specs/agent-skills.md lines 144-147, align
the documented budget with the enforced value or lower the test to approximately
140 characters; in packages/lib/src/commands/__tests__/starter-skills.test.ts
lines 22-28, set MAX_DESCRIPTION_CHARS to the agreed documented limit and update
its comment to reference the specification.
In `@packages/lib/src/commands/starter-skill-installer.ts`:
- Around line 97-103: Update installStarterSkills to acquire a row-level lock
when reading the user record, then perform the starter-skills installation and
starterSkillsInstalledAt stamp update within the same transaction. Add proper
conflict handling so concurrent callers cannot both proceed after observing
NULL, and ensure this protection does not depend on locks held by callers.
- Around line 143-159: Update the command insertion flow using the existing
insert chain and onConflictDoNothing to call returning(), then only push
skill.trigger into installed when a row was returned; otherwise record the
trigger in skipped. Change skipped from const to a mutable declaration if
needed, preserving accurate backfill reporting when the command already exists.
In `@scripts/backfill-starter-skills.ts`:
- Around line 95-105: Update the final noHomeDrive calculation in the backfill
flow to exclude users recorded in summary.failed, so failed transactions are not
reported as benign no-Home-drive users. Ensure the count is scoped to users
present at the start of the walk, or rename the field and its “left unstamped”
reporting to accurately describe the broader set being counted.
---
Nitpick comments:
In `@apps/web/src/lib/ai/core/plan-binding.ts`:
- Line 90: Update the stable prompt construction around the plan context
sentence to remove the mutable plan.title value from the cached content,
retaining only the stable plan.pageId; if the title must remain visible, render
it in the deliberately volatile prompt section instead.
In `@scripts/backfill-starter-skills.ts`:
- Around line 69-74: Update the dry-run path in the batch loop of the backfill
script to query each batch’s existing command triggers, count overlaps with
STARTER_SKILL_TRIGGERS, and increment summary.installed only for non-colliding
triggers. Also increment summary.skippedCollision for those overlaps so dry-run
totals match the real run.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 8f0b5c8f-ba82-4a79-b0cc-32aad717255e
📒 Files selected for processing (44)
README.mdapps/admin/src/lib/onboarding/__tests__/home-drive.test.tsapps/admin/src/lib/onboarding/home-drive.tsapps/marketing/src/app/docs/getting-started/page.tsxapps/web/src/app/api/ai/chat/route.tsapps/web/src/app/api/ai/conversations/[conversationId]/plan/route.tsapps/web/src/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.tsapps/web/src/app/api/ai/global/[id]/messages/route.tsapps/web/src/app/api/ai/page-agents/[agentId]/conversations/[conversationId]/__tests__/route.test.tsapps/web/src/app/api/ai/page-agents/[agentId]/conversations/[conversationId]/messages/__tests__/route.test.tsapps/web/src/app/api/auth/__tests__/me.test.tsapps/web/src/app/api/v1/chat/completions/__tests__/route-backfill.test.tsapps/web/src/app/api/v1/chat/completions/__tests__/route.test.tsapps/web/src/app/api/v1/conversations/__tests__/route.test.tsapps/web/src/components/ai/shared/PlanChip.tsxapps/web/src/components/ai/shared/chat/tool-calls/PlanBindingRenderer.tsxapps/web/src/components/ai/shared/chat/tool-calls/registry.tsxapps/web/src/components/ai/shared/index.tsapps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsxapps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsxapps/web/src/lib/ai/core/__tests__/ai-tools.test.tsapps/web/src/lib/ai/core/__tests__/location-prompt.test.tsapps/web/src/lib/ai/core/__tests__/plan-binding.test.tsapps/web/src/lib/ai/core/ai-tools.tsapps/web/src/lib/ai/core/home-drive-hint.tsapps/web/src/lib/ai/core/location-prompt.tsapps/web/src/lib/ai/core/plan-binding.tsapps/web/src/lib/ai/core/skill-catalog.tsapps/web/src/lib/ai/tools/plan-tools.tsapps/web/src/lib/onboarding/__tests__/home-drive.test.tsapps/web/src/lib/onboarding/home-drive.tsdocs/specs/agent-skills.mdpackages/db/drizzle/0246_sticky_venus.sqlpackages/db/drizzle/meta/0246_snapshot.jsonpackages/db/drizzle/meta/_journal.jsonpackages/db/src/schema/auth.tspackages/db/src/schema/conversations.tspackages/lib/package.jsonpackages/lib/src/commands/__tests__/starter-skill-installer.test.tspackages/lib/src/commands/__tests__/starter-skills.test.tspackages/lib/src/commands/starter-bodies/plan.tspackages/lib/src/commands/starter-skill-installer.tspackages/lib/src/commands/starter-skills.tsscripts/backfill-starter-skills.ts
…installer races, budget guards Seven review threads, all verified against source before acting. SECURITY (thread 3, plan-binding.ts). getActivePlan used canUserViewPage, which answers "can this human see it" — strictly wider than "may this token reach it". The page-chat route also serves scoped MCP/OAuth tokens, so a token scoped to drive A could see the title and id of a plan the user bound in drive B. This matters more here than for the neighbouring Agent Memory section, which gets away with a user-level check because an Agent Memory page is by construction a child of the agent page and therefore always same-drive; a plan binding can point anywhere. The check is now injected, defaulting to the user-level check for the session-only Global Assistant and passed canPrincipalViewPage (the repo's own principal-aware primitive, which also covers OAuth drive scopes and the token's RBAC ceiling) from the page-chat route. The reviewer flagged the read path; the write path had the same hole, so set_plan now uses canActorViewPage. Binding a page the ACTOR cannot reach would otherwise smuggle its title back through ACTIVE PLAN on every later turn. CONCURRENCY (thread 5, installer). installStarterSkills read the stamp without a lock. Provisioning happens to hold FOR UPDATE on the user row; the backfill script does not, so two concurrent runs could both see NULL and both install. Serialization now belongs to the installer, not its callers — re-locking a row the transaction already holds is a no-op, so provisioning pays nothing. CORRECTNESS (thread 6, installer). onConflictDoNothing can skip the command insert, but the trigger was pushed to `installed` unconditionally, over-reporting in the backfill summary and orphaning the just-created page in the Skills folder — a document that looks like a skill but can never be invoked. Now detects the skip via returning(), deletes the orphan, and reports it as skipped. REPORTING (thread 7, backfill). `noHomeDrive` counted every still-unstamped user, which includes ones whose transaction failed (stamp rolled back) and ones created mid-walk, so an operator could read real failures as benign. Renamed to unstampedRemaining, known failures subtracted, label corrected. BUDGET (thread 4, spec vs test). Spec said ~140 chars, test enforced 200, so a 180-char starter passed every check. 200 is the number with a mechanism behind it — it is COMMAND_CATALOG_FULL_CLIP, past which the catalog clips anyway. But the per-description cap cannot catch crowding on its own, so the real guard is new: the whole starter set must render within 40% of COMMAND_CATALOG_CHAR_BUDGET, which is what actually bites as orchestrate/research/execute land. STALENESS (thread 2, PlanChip). The chip only revalidated after its own clear button, so an agent-driven set_plan left it showing nothing. It now watches the message stream for COMPLETED plan-tool calls as an invalidation signal only — the binding still comes from the server, which is what lets it survive a reload and a summary. In-flight calls are ignored so streaming deltas don't thrash it. COVERAGE (thread 1, route). Added 12 contract tests for the plan endpoint: ownership isolation returning 404 not 403 (a 403 would confirm existence), trashed and no-longer-viewable suppression, DELETE behind CSRF, successful unbind touching only the binding. Gates: monorepo typecheck, build and lint clean; lib 8,748, web 16,170, admin 178 passing against a freshly migrated database. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KebqPHDM6zHpKV7VPt2Fa
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/src/components/ai/shared/PlanChip.tsx (1)
116-118: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winAdd an accessible name to the active-plan trigger.
The icon-only button has no explicit accessible name. Add
aria-label="Active plan"so assistive technology identifies the control.Proposed fix
- <Button variant="ghost" size="icon" className="relative h-8 w-8" title="Active plan"> + <Button + variant="ghost" + size="icon" + className="relative h-8 w-8" + title="Active plan" + aria-label="Active plan" + >🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/components/ai/shared/PlanChip.tsx` around lines 116 - 118, Add aria-label="Active plan" to the icon-only Button in PlanChip so assistive technology has an explicit accessible name, while preserving its existing title and styling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/lib/src/commands/__tests__/starter-skill-installer.test.ts`:
- Around line 206-213: Update the test around installStarterSkills to have the
fake client record operation order, then assert that the user-row lock occurs
before the first user-stamp read. Keep the existing lockedRows assertion, but
ensure the new assertion distinguishes lock-before-read from merely confirming
execute ran.
---
Outside diff comments:
In `@apps/web/src/components/ai/shared/PlanChip.tsx`:
- Around line 116-118: Add aria-label="Active plan" to the icon-only Button in
PlanChip so assistive technology has an explicit accessible name, while
preserving its existing title and styling.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: f02456db-6faa-4aae-8b30-06aed3dcad29
📒 Files selected for processing (14)
apps/web/src/app/api/ai/chat/route.tsapps/web/src/app/api/ai/conversations/[conversationId]/plan/__tests__/route.test.tsapps/web/src/components/ai/shared/PlanChip.tsxapps/web/src/components/ai/shared/__tests__/PlanChip.test.tsxapps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsxapps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsxapps/web/src/lib/ai/core/__tests__/plan-binding.test.tsapps/web/src/lib/ai/core/plan-binding.tsapps/web/src/lib/ai/tools/plan-tools.tsdocs/specs/agent-skills.mdpackages/lib/src/commands/__tests__/starter-skill-installer.test.tspackages/lib/src/commands/__tests__/starter-skills.test.tspackages/lib/src/commands/starter-skill-installer.tsscripts/backfill-starter-skills.ts
🚧 Files skipped from review as they are similar to previous changes (9)
- apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx
- apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx
- packages/lib/src/commands/starter-skill-installer.ts
- packages/lib/src/commands/tests/starter-skills.test.ts
- scripts/backfill-starter-skills.ts
- docs/specs/agent-skills.md
- apps/web/src/app/api/ai/chat/route.ts
- apps/web/src/lib/ai/core/plan-binding.ts
- apps/web/src/lib/ai/tools/plan-tools.ts
Two follow-ups from the second review pass. The lock test only proved `execute` ran; it still passed with the lock taken AFTER the stamp read, which would leave the read-then-stamp exactly as racy as no lock at all. The fake client now records an ordered operation log and the test asserts the lock precedes the first user read. Verified by mutation: moving the lock below the read fails the assertion, restoring it passes. PlanChip reacted to any non-zero count of completed plan-tool calls, so opening a conversation whose history already contains a `set_plan` fired a redundant revalidation on top of SWR's own initial fetch. It now tracks the previously seen count and reacts only to a NEW completion. Two tests pin it: no double fetch on mount with prior history, and no fetch when messages change without a plan-tool completion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KebqPHDM6zHpKV7VPt2Fa
Deep review round (commits
|
| Issue | Why it mattered |
|---|---|
set_plan required strict conversation ownership |
/api/ai/chat explicitly authorizes non-owners to post to a shared conversation (ownsIt || isSharedConversation). A collaborator running /plan would watch the agent write the plan page and then be told the binding failed — the exact lost-after-compaction hole the feature exists to close. Writability now mirrors the route's own gate. |
| PlanChip revalidation was dead code on the Global Assistant | set_plan is not in CORE_TOOL_NAMES and that route always splits its toolset, so the agent can only reach it as execute_tool({tool_name:'set_plan'}) — a tool-execute_tool part my top-level name match never saw. The fix I shipped in the previous commit therefore did nothing on the one surface where the chip is most visible. Now unwrapped. |
resolveHomeDriveHint leaked past MCP drive scope |
The same leak I had just fixed for the plan section, in the file next door. A drive-scoped token got a Home driveId; since the /plan body sends driveless plans to Home, it was then steered into a create_page that driveOutsideMcpScope rejects every time. |
| Two installer docblocks were false, not merely unclear | starterSkillsInstalledAt is a one-shot marker, not the "high-water mark" I called it — appending to STARTER_SKILLS later reaches new users only, and the backfill cannot even see stamped users. That silently blocks the orchestrate/research/execute follow-ups this PR promises, so the limitation and what fixing it needs (per-trigger provenance — a schema change) are now spelled out. Separately, the FOR UPDATE said to make serialization "owned by this function" was a no-op under the default client = db, since each statement autocommits; the default is removed so a transaction is required, which both real callers already pass. |
| Self-contradictory prompt | "Do NOT assume the Home drive" sat immediately above a line handing the model the Home driveId. Resolving that ambiguity the wrong way is the bug the guard was added to prevent. The guard is now scoped to general work and the id labelled off-limits unless named. |
Refuted — verified, no change
The claim that removing the isTaskLinked branch drops task-linked guidance. Neither producer on the AI location path (resolvePageContext, pageContextToLocationContext) has ever set that flag; the page-tree APIs that populate it are a different code path. The branch was genuinely dead.
Documented rather than fixed
Three findings were really "the comment overclaims", so 3fc99d8 makes the claims true:
- The section changes on
set_plan/clear_plan, on a rename of the bound page, and on a visibility change. The property that actually justifies the cache-stable placement is narrower than I wrote: none of those track navigation. ACTIVE PLANrenders on two routes whileset_planships in the shared tool set, so on three others the binding persists but is not echoed into that surface's own prompt — the same callable-but-unadvertised positionload_skillalready occupies.- Bind time uses
canActorViewPage(agent-aware), render time usescanPrincipalViewPage. Closing that needs one actor abstraction shared by the prompt and tool layers; recorded with what it would take rather than left looking like an oversight.
Verification
Monorepo typecheck, build and lint clean. lib 8,748 · web 16,184 · admin 178 passing against a freshly migrated database.
One note on method: three global-route test files failed after the scope fix because their @/lib/auth mocks were incomplete. I completed the mocks rather than weakening the code — but in the earlier round the same symptom meant the code was genuinely fragile (a missing dependency threw before .catch() could attach), and there I fixed the code instead. Worth distinguishing the two.
|
…e epic branch Brings master's `/plan` editable starter skill and its durable conversation↔plan binding into the "Agent-Session Single Source of Truth" epic branch, which had restructured the same surfaces. Migrations — RENUMBER, not squash. Master shipped its own 0246 (`0246_sticky_venus`, users.starterSkillsInstalledAt + conversations.planPageId); ours have never been applied outside scratch databases, so ours move: 0246–0253 → 0247–0254. Every SQL body is byte-identical (sha256 verified before and after; the migration runner hashes the file, so the applied-hash list is independent confirmation). That matters because ~600 lines of the epic's SQL is hand-appended `DO $$` data migration a schema diff can never re-emit — the 0249 orphan-conversation synthesis ladder, the 0251 VALIDATE CONSTRAINT audit, the 0253 final sweep, completeness guard and stale-twin repair — and because 0249 ADDs `messages.pageId` while 0253 DROPs it, an intermediate state a net diff would erase along with the reader cutover that ran on it. SQL comments still cite the migrations by their pre-renumber numbers, deliberately: preserving the bytes was worth more than the cosmetic renumber of prose. Snapshots were rebuilt as a chain rather than regenerated: master's 0246 snapshot is the base (restored with an explicit `git checkout origin/master --`), our eight carry master's exact plan-binding delta and re-point their prevId. `drizzle-kit check` passes, `drizzle-kit generate` reports no schema changes, and 0000→0254 applies clean on a fresh Postgres. `0247`'s journal `when` moved forward by 6.4s to stay ordered after master's 0246 — this repo's runner keys on hash, but the stock drizzle-orm migrator skips any entry whose folderMillis trails the last applied one. `/plan` behaviour was PORTED, not resurrected. Master added it to the old fat route bodies; this epic collapsed both routes into `lib/ai/chat-pipeline/`, so `resolveHomeDriveHint` + the cache-stable `ACTIVE PLAN:` section now live in `page-chat-turn.ts` (principal-aware `canPrincipalViewPage`, since scoped MCP/OAuth tokens reach that turn) and `global-chat-turn.ts` (user-level check, session-only surface). The old route files stay thin. `conversations` keeps both sides: `rev`, `workspaceId`, `closedInWorkspaceAt`, `agentPageId` from the epic and `planPageId` from #2359. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XnzUuAixdTJ8xDpP5S92Ts
The eight epic migrations moved up one slot to make room for master's 0246 (#2359). These seven suites locate their migration by filename prefix, assert its journal `idx`, and slice `readMigrationFiles()` positionally to drive a real chain — all of which are positions, and all of which moved. Only positions changed. Assertions about the migration TEXT keep their original numbers, because the SQL bodies are byte-identical: the expand suite still expects `0248-messages-unification-indexes.sql` inside the SQL (and still asserts that script does not exist on disk), and the validate-FK suite still matches `/0248/` in the RAISE output. That split is the point — an applied migration's text is never edited, so its prose and its filename can legitimately disagree after a renumber. Verified against a real Postgres: all four live chain suites (0249/0250 expand, 0251 validate, 0253 drop, 0247→0252 pane grid) execute rather than skip. @pagespace/db: 636 passed, 42 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XnzUuAixdTJ8xDpP5S92Ts
The epic added `scripts/lib/tenant-export-columns.ts`, a registry gate that fails when a schema column is neither carried by the tenant export nor explicitly excluded — an unlisted column is dropped silently by every tenant migration. Master's #2359 added `users.starterSkillsInstalledAt` and `conversations.planPageId` against a branch where that gate did not exist, so the merge is the first thing to see them. Both are real gaps, not bookkeeping: - `starterSkillsInstalledAt` MUST travel. The starter skills themselves are ordinary pages plus personal `commands` rows, which the bundle already carries; a tenant arriving with the stamp cleared would have Home provisioning seed a second copy of every starter skill on top of the user's own edited ones. - `planPageId` travels, and gets `nullifyOrphanedPageRefs` alongside `agentPageId` — more necessary here than there, because a plan binding may legitimately point at a page in ANOTHER drive and is therefore the FK most likely to fall outside a scoped bundle. Nulling unbinds the thread, which is what its ON DELETE SET NULL already means, instead of failing the import on a dangling reference. scripts suite: 265 passed (was 2 failed). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XnzUuAixdTJ8xDpP5S92Ts
…owups fix(ai): plan-binding correctness follow-ups missed by the #2359 merge
Why
PageSpace agents had no planning affordance. Read-only mode already frames itself as plan mode —
READ_ONLY_CONSTRAINTliterally says "Focus on exploring, analyzing, and planning / Create actionable plans for the user to execute later" — but nothing taught the agent how to produce a plan, and nothing let a conversation remember which plan it was working against.Prior art is unanimous that the plan must be a durable artifact (Cursor
.plan.md, Cline's/deep-planning→implementation_plan.md, Claude Code's plan file, Copilot Workspace attaching it to the PR), and the stated reason is context compaction: a file/page re-read is deterministic where a summary only approximately remembers. The documented anti-pattern is re-read thrash, which argues for a small durable pointer plus deliberate re-reads rather than bulk re-injection.PageSpace had the compaction problem and none of the plumbing.
conversationshad no page association at all; "where am I" was rebuilt from the browser URL every turn and thrown away.Two tiers of skill, split by ownership
A built-in skill teaches PageSpace's own mechanics (how a CANVAS page works, what
SUBTASKS_INCOMPLETEmeans). Users have no reason to edit those; a deploy is the right way to change them.A starter skill is methodology, where the useful property is that the user can disagree with it and rewrite it. So
/planseeds once as a real DOCUMENT atHome / Skills / Planplus a personalcommandsrow, and then belongs to the user — the registry is a seed, not a live source, and edits there never reach an existing install.Idempotence rides
users.starterSkillsInstalledAt, deliberately not "does a command with this trigger exist?". Only a stamp both refuses to resurrect a starter the user deliberately deleted and lets the backfill re-run safely after a partial failure.provisionHomeDriveIfNeeded— both drive-creation branches, same transaction, web + admin copies kept in sync.scripts/backfill-starter-skills.ts(--dry-runsupported). No login-path change, so the auth hot path is untouched.The binding, and why it lives in the cache-stable prompt
conversations.planPageId(migration0246), set by newset_plan/clear_plantools, renders anACTIVE PLAN:section into the cache-stable system prompt.That placement is the requirement, not an optimization. The compaction summary at
ModelMessages[0]is lossy, so a pointer carried in the message stream gets summarized away exactly when it is most needed — the same reasonTasksDropdown's message-derived task binding empties on truncation. The system prompt is never compacted, so the pointer survives by construction.It stays cache-safe because the value changes only on an explicit tool call and never on navigation: one prefix invalidation per plan change, the same trade Agent Memory already accepts.
Plan page titles are user-authored and land verbatim in the system prompt, so they pass
clipDescriptionfirst — an unsanitized title with newlines could otherwise forge a whole instruction section. There's a test for that.Authorization is principal-aware, not user-level. The page-chat route also serves scoped MCP and OAuth tokens, and unlike the neighbouring Agent Memory section (whose page is by construction a child of the agent page, hence always same-drive) a plan binding can point at a page in any drive. So
getActivePlantakes an injected check —canPrincipalViewPageon the page-chat route, defaulting to the user-level check for the session-only Global Assistant — andset_planusescanActorViewPage. "Can this human see it" is strictly wider than "may this token reach it"; without the distinction a token scoped to drive A could read the title and id of a plan bound in drive B.PlanChipsits besideTasksDropdownin both chat headers but reads the persisted binding, so it survives a reload and a summary.Where plan artifacts go (resolved an open question in the epic)
Plansfolder at that drive's root; plans live next to the work, visible to drive members.To make the second half reachable,
buildLocationTurnPromptnow renders the Home drive id in its no-location branch only —list_drivesexposes neither drive kind nor a Home marker, so an agent standing nowhere previously had no way to name Home. The standing "do NOT assume the Home drive" guard is unchanged; the skill body carries the narrow exception.Drive-by fix
buildLocationTurnPromptwas silently droppingcurrentPage.ideven thoughLocationContextcarried it — the model could only address pages by title, which is ambiguous across drives and changes under it on rename. Now rendered, in the volatile block, at zero cache cost. DeadisTaskLinkedremoved from this prompt only (still live in the page tree and page-read tools).Deliberate calls worth a reviewer's eye
set_plan/clear_planare not inWRITE_TOOLSso they survive read-only mode. They mutate conversation metadata, not user content, and binding an existing plan doc is a legitimate read-only action.create_page, so/plancannot write its artifact there. The body says so plainly and offers to save once write mode is on, rather than thrashing. A carve-out would need a new predicate alongsidefilterToolsForReadOnly— out of scope here.prepareStepreturns only{ messages }; there is noexperimental_activeTools)./planinstructs, it does not enforce.Verification
Monorepo
typecheck,build, andlintclean.@pagespace/libwebadminRun against a scratch Postgres with all 246 migrations applied. Three initially-failing web files and the admin broadcast failures were an unmigrated local test DB (Postgres
42703); one (grouping.test.ts) is a pre-existing TZ dependency in a file this PR never touches and passes underTZ=UTC. Each was verified individually rather than assumed.The generated
0246_snapshot.jsonaccounts for ~20.9k of the diff; the hand-written change is ~1,600 lines.Review round (commits 2 and 3)
All eight CodeRabbit threads were verified against source before acting; none was dismissed. Beyond the literal findings:
set_plan) had the same hole, so it was fixed too.COMMAND_CATALOG_FULL_CLIP, the point past which the catalog clips anyway. But a per-description cap cannot catch N starters crowding out the user's own commands, so the aggregate guard (≤40% ofCOMMAND_CATALOG_CHAR_BUDGET) is the one that will actually bite whenorchestrate/research/executeland.onConflictDoNothingskip left an orphaned page in the Skills folder — a document that looks like a skill but can never be invoked. It's now deleted, not just reported correctly.FOR UPDATEbelow the stamp read fails it, restoring it passes.Follow-ups (not in this PR)
orchestrate/research/execute/brainstormreuse this machinery — but not for existing users until a starter ledger lands. Review found thatusers.starterSkillsInstalledAtis a one-shot marker, not the high-water mark it was documented as: adding a second starter reaches new signups only, and the backfill cannot see already-stamped users. Adding it without resurrecting a starter the user deleted needs per-trigger provenance, i.e. a schema change. Recorded in the installer docblock and on the epic so the next PR can't miss it.canvas-websiteset al.) from the/picker.TasksDropdowncould read a persisted binding instead ofuseAggregatedTasks, fixing its truncation behavior and the model's blindness to it in one move.todo_listmessage path (TodoListMessage,CompactTodoListMessage, and a loader calling/api/ai/tasks/by-message/{id}— a route that does not exist) is dead code worth deleting.Deploy note
Requires
bun run db:migratebefore the new image takes traffic (both new columns are nullable, so the old image tolerates them).🤖 Generated with Claude Code
https://claude.ai/code/session_011KebqPHDM6zHpKV7VPt2Fa
Summary by CodeRabbit
New Features
Documentation