.env.example ships AGENT_API_TOKEN=replace-me, and unlike PB_SUPERUSER_PASSWORD's
own replace-me placeholder, an unchanged agent token fails open: any caller
sending "Bearer replace-me" would get full owner-equivalent access. Treat a
secret that is unset, literally "replace-me", or shorter than 32 characters
as not configured, matching the existing 500 behavior for an unset token.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Added `NEXT_PUBLIC_APP_URL` to `.env.example` for app URL configuration.
- Updated `README.md` to document the new `NEXT_PUBLIC_APP_URL` variable.
- Improved CSS to support light and dark color schemes.
- Refactored intake link actions to utilize the new app URL and handle token generation more efficiently.
- Enhanced public intake route to validate work area selection.
- Updated `IntakeLinksPanel` to allow copying of generated links.
- Improved error handling and user experience in `BriefPreviewPublic` and `IntakePublic` components.
- Refined theme synchronization and toggle components for better user preference management.
Pure helpers for the Gantt's drag/double-click math:
- fractionToDate(fraction, range): what ISO date a fractional
position in the chart represents, snapped to whole UTC days.
- shiftDatesByFraction: move a bar's start and target by the
same delta, clamped to the visible range, with start dropped
when clamping would invert or collapse the span.
- resizeEndToFraction: what the new target date becomes when
the user drags a right edge.
- moveCardByFraction: alias that produces the action's input
shape.
11 unit tests cover the corners (no dates set, point bar,
clamping past the right edge, inversion guard).
rescheduleCardAction also drops its owner-only gate. Any
signed-in user — owner or client manager — can move dates
now. PocketBase's existing row-level rules govern which cards
a given user can write (a client manager still only writes
their own client's cards); the activity-log row is unchanged
and attributes the move to the actual user either way.
Adds setSubtaskDatesAction so the same drag/double-click
gestures work on subtask rows. No audit log on subtasks —
matches the existing pattern (subtask toggles don't log).
New loader returns the sectioned shape the Gantt view needs:
- buildPlanSections (pure): groups cards by project, sorts by
start_date asc / title, splits subtasks into dated vs undated.
Five unit tests cover the grouping and sorting rules.
- loadPlan (server-only): one PocketBase trip per collection,
delegates to buildPlanSections.
471/471 unit tests pass (was 466, +5 new).
Three new optional fields, idempotent ensure helpers:
- cards.assignee: free-text label rendered above the card bar
on the new /plan Gantt. Empty by default.
- subtasks.start_date: ISO date or empty. Lets subtasks appear
on the Gantt under their parent card with their own duration.
- subtasks.target_date: same, for the bar end.
Live migration confirmed idempotent — first run added the
fields, second run reported both already present.
The branch plan-puzzle-v1-tests had accumulated changes from a
sibling thread (Plan Puzzle v1 backend + frontend) that hadn't
been committed yet, plus a new src/lib/git/repo-url helper used by
the Forgejo ingest path. Pick them all up so the working tree is
clean and the branch reflects everything since its fork.
Files (Plan Puzzle):
src/app/actions/client-proposal.ts
src/app/actions/owner-plan.ts
src/app/actions/reschedule.ts
src/app/actions/reschedule-helpers.ts (+ test)
src/components/admin/project-form.tsx
src/components/roadmap/roadmap-builder.tsx
src/components/roadmap/timeline-view.tsx
src/lib/cards/write-with-audit.ts (+ test)
src/lib/roadmap/timeline.ts (+ test)
New helper:
src/lib/git/repo-url.ts (+ test)
Verified: npm run build clean, 466/466 unit tests pass.
Phase 9c. 12 tests covering:
- picker page (multi-project list, single-project redirect,
zero-projects fallback)
- wizard URL (project-on-client 200, cross-client 404,
show_in_intake=false 404)
- submission flow (card lands on chosen project's inbox,
cross-client refused, hostile body fields ignored,
per-(link, project) cap isolates one project from another)
- Phase 8 backwards compat (per-project link redirects to the
wizard; the existing Phase 8 submission route still accepts
Phase 8 links)
Three bridges from the plan:
1. PocketBase stores date as 'YYYY-MM-DD 00:00:00.000Z'; the
filter must use that form. Passing 'YYYY-MM-DD' silently
matches zero rows, even though the create succeeds.
2. PocketBase's getFirstListItem returns 404 for a row just
written by the same admin client; getList works. The helper
uses getList with perPage=1.
3. PocketBase's read-after-write latency is significant.
isLinkProjectUsable retries with exponential backoff for
up to ~2.5s before giving up; bumpDailyCapForProject's
upsert pattern uses a fallback read+update path if the
create hits the composite unique index from a peer's
concurrent write.
Phase 9b Task 3. listPickerProjects reads the public-eligible
projects on a client; resolveProjectForClient checks that a given
projectId belongs to the client and is opted in. Both fail closed
on any DB read error.
Phase 9a Task 6. Three integration tests:
- schema lands: row inserts with link/project/date/count fields,
composite unique index is the only constraint (different
project same link + date is allowed)
- duplicate (link, project, date) is rejected by the unique index
- the public route returns a sensible status, never 500, on a
Phase 9 link (the route will be wired in 9b; 9a only proves
it does not crash today)
Two bridges from the plan:
- PocketBase's date field defaults to unique=true; my schema
fix flipped it via ensureIntakeLinkSubmissionsDateNotUnique.
The relation and number fields appeared to share the same
default but getOne()'s response omits the key, so the
explicit unique=false on those doesn't stick — yet rows with
the same link and different projects work in practice (the
composite index does its job). Worth a follow-up if a future
PocketBase release tightens the default.
- bumpDailyCapForProject wraps the read-then-create-or-update
in a retry on the unique constraint to handle PocketBase's
read-after-write latency window. The cap-isolation property
is verified end-to-end in 9c.
Phase 9a Task 3. isLinkProjectUsable reads today's counter (or
treats no row as 0). bumpDailyCapForProject upserts via the unique
(link, project, date) index. The Phase 8 helpers stay live for the
old shape; Phase 9b's wizard picks which one to call based on
whether the link is per-project (Phase 8) or per-client (Phase 9).
Phase 9a Task 2. Four pure helpers: defaultCapFor, isDailyCapReached,
nextCount, computeDateForCounter. 7 unit tests cover the caps
boundary (cap=0 means 'no submissions today'; todayCount === cap
is reached), the count increment, and the local-date format.
Note: project convention puts unit tests next to source
(src/**/*.test.ts), not under tests/unit/. Adjusted from the plan.
Phase 9a Task 1. Additive only — the Phase 8 shape stays live.
intake_links.project becomes optional so Phase 9 rows can leave it
empty. intake_links.client is the new owner of the link, read by
the picker page in 9b. projects.show_in_intake defaults to false
and is the knob that hides a project from the picker. The new
intake_link_submissions collection tracks the daily counter per
(link, project, date) so the cap applies per project, not per link.
Type additions:
- ProjectRecord.show_in_intake: boolean
- IntakeLinkRecord.client: string (Phase 9)
- new IntakeLinkSubmissionRecord
Phase 8c Task 4. Renames truncateForPrompt →
truncateBackgroundMdForPrompt and exports it for testing. Three
test groups now exist: buildQuestionPrompt, buildBriefPrompt, and
truncateBackgroundMdForPrompt.
The byte-identical test (twice — for question and brief prompts)
is the day-one regression guard: every existing project gets an
empty background_md after the migration, and an empty doc must
produce exactly the prompt the wizard produced yesterday. A diff
here breaks every in-flight interview on the day 8c ships.
Phase 8c Tasks 2 + 3. IntakeContext gains backgroundMd; intakeContext
in interview.ts reads project.background_md, trims it to the
30000-char prompt cap with a marker. Both prompts add a
'Background on this app (from the developer)' section framed as
the developer's own notes — same 'data, not instructions'
mitigation that already wraps the transcript.
Phase 8c Task 1. Optional text field on projects, max 40000 chars.
The prompt module caps at 30000 with a truncation marker. Empty
documents produce byte-identical prompts to today — the test in
Task 4 locks that in.
Phase 8a Task 5. resolveIntakeLink returns null for unknown tokens
(spec §3: caller gives the same response as for a revoked token).
isLinkUsable lazily resets today's counter on date rollover.
validateIntakeLinkForStep returns a three-valued LinkState so the
route can distinguish 'inactive/expired' from 'daily-cap'.
Phase 8a Task 4. 60 requests per 5-minute window per opaque key.
In-memory, lossy on restart, no IP persistence. Backed by the
daily cap on intake_links for coarse-grained abuse control.
Phase 8a Task 3. Reads x-forwarded-for (first hop), then x-real-ip,
falls back to 'unknown'. Not used for auth or logging — pure rate-
limit keying so concurrent clients do not share one bucket.
Phase 8a Task 2. 24 bytes of CSPRNG entropy, base64url-encoded to
exactly 32 URL-safe characters. isIntakeToken matches that exact
shape so the public route can fail fast on bogus input without
hitting the database or charging the rate limiter.
Phase 8a Task 1. Adds the new intake_links collection (per-token
public entry point, owner-only rules), extends cards.created_by_role
with 'anonymous', and extends activity.action with
'card_submitted_anonymously'.
Note: getOrCreateCollection is a no-op on existing collections, so
the two existing-value extensions are applied by new/extended
ensure functions (ensureCardsCreatedByRoleValues and
ensureActivityActionValues respectively). Re-runs are idempotent.
Adds the three proposal fields (proposed_target_date, proposed_by,
proposed_at) to the cards collection with a partial index that backs
the 'pending proposals' query, plus two new ActivityAction values
(date_proposed, date_proposal_resolved) and column_changed for
return-to-scheduled.
New server actions under src/app/actions/:
- client-proposal.ts: client-manager drag creates a proposal (not
a target_date write); 404 (not 403) on cross-tenant failures and
Done-column cards, matching the rest of /api/client/*.
- owner-plan.ts: accept and dismiss clear the three proposal
fields together and emit the audit rows; returnToScheduledAction
moves a Done card back to the first working column.
Server-side Done-column guards added to:
- src/app/actions/reschedule.ts (was UI-only)
- src/app/api/client/cards/[id]/route.ts (was UI-only)
Both pass through the new writeCardWithAudit helper at
src/lib/cards/write-with-audit.ts — single source of truth for
'patch card + emit activity rows', replacing the inline copies in
the PATCH route and rescheduleCardAction.
Adds src/lib/roadmap/slot-date.ts (slot <-> target_date mapping)
consumed by Pixel's puzzle UI; named SlotId API rather than the
plan's numeric indices for self-documenting callers.
CLIENT_EDITABLE_CARD_FIELDS stays at priority + target_date per
the brief; proposals go through the dedicated action so they get
their own audit row type.
The Plan page replaces the read-only Bands tab with a drag-and-drop
puzzle surface. Both roles drag; owners write target_date directly via
the existing rescheduleCardAction, clients write proposed_target_date
via proposePlanMoveAction. Done-column cards are excluded; they live
on the Timeline tab with a new 'Return to scheduled' button.
UI:
- Tray (unscheduled) + 3 slot columns: This Month / Next / Month After.
Slot headers carry count + '+N more' chip beyond 12 cards. Tray
virtualises via scroll-window math at 50+ cards.
- HTML5 drag-and-drop matching the existing PendingTray pattern; the
PuzzleView keeps the optimistic-state pattern from the kanban Board
(no router.refresh until the server confirms).
- Keyboard parity: Tab focuses a card, Space opens a listbox (3 slot
options), 1/2/3 picks a slot, Escape closes, ArrowUp/Down reorders
within the slot (optimistic local display; no v1 server action for
in-slot position). aria-live='polite' announces each successful move.
- prefers-reduced-motion via Tailwind motion-safe: variants — the 180ms
settle animation is dropped for users who request it.
Proposal overlay:
- RoadmapCard shows a left-edge accent stripe + Accept/Dismiss chip
when proposedTargetDate is set and the viewer is the owner.
- The existing RoadmapCard primitive is reused — no new card component.
Return-to-scheduled button:
- New component on Done-card rows in TimelineView. Replaces the
drag-sideways motion that the new Done-column server guard blocks.
- Owner-only, calls returnToScheduledAction.
Toast system:
- New ui/toast.tsx: useToast() hook + top-right aria-live region, 4s
auto-dismiss. AppShell mounts the provider so any page can call it.
- roadmap-builder.tsx no longer console.errors on a failed drag.
Server action stubs:
- client-proposal.ts and owner-plan.ts: temporary no-op stubs that
return a non-ok response. Deleted when Lin's branch lands; the
dynamic imports in the UI call them lazily so a missing module
surfaces through the toast rather than crashing.
Type widening:
- src/lib/roadmap/load.ts: adds optional proposedTargetDate,
proposedBy, proposedAt, isDone fields to the RoadmapCard interface
so the Puzzle compiles against pre-migration data. Lin's branch
populates and tightens these.
Files (15 changed):
- New: slot-date.ts + tests (slot↔date mapping)
- New: puzzle-keyboard.ts + tests (pure listbox state machine)
- New: toast.tsx, proposal-overlay.tsx, return-to-scheduled-button.tsx
- Rewritten: band-view.tsx now exports PuzzleView (per task brief)
- Updated: roadmap-tabs, roadmap-card, roadmap-builder, timeline-view,
plan/page, app-shell, load.ts
Tests (38 new):
- 18 unit tests for slot-date.ts (month boundaries, year rollover,
past/future, mid-month symmetry)
- 20 unit tests for the keyboard listbox state machine
(nextListboxState + isCardShortcut)
UX decisions worth flagging:
- ArrowUp/Down reorders are optimistic only — the order resets to
canonical (priority desc, title asc) on the next page load. No v1
server action exists for in-slot position; v2 should add one.
- Done cards are excluded from the Puzzle (per spec §6.1); the
Return-to-scheduled button lives on the Timeline tab (per task brief).
- A drop on the tray itself is a no-op in v1 — there is no
'unschedule' action, and the spec lists only 1/2/3 for the keyboard
listbox.
getCurrentUser() only decoded the pb_auth cookie locally and trusted its
embedded role/client/active fields directly — the PocketBase JS SDK holds
no secret to check a JWT signature against, so a completely forged cookie
(garbage signature, role: "owner", any email) was accepted as a real
session. Verified exploitable end-to-end: a raw Cookie header against the
live app, no browser and no password, rendered the owner's home page.
This mattered most for actions writing through the superuser connection
(log-work.ts, summarise.ts, ingest.ts, and critically users.ts for account
creation) — those never route the caller's token through PocketBase's own
rule engine at all, so the app-level role check was the ONLY gate, and it
was reading an unverified claim. Actions using the caller's own token
(cards.ts, projects.ts) still made the wrong authorization DECISION from
the forged role, even though PocketBase's rule engine would have rejected
the actual data write.
Fixed by having getCurrentUser() call authRefresh(), which asks PocketBase
to confirm the token is genuine; a forged or tampered token is refused
there and treated as signed-out. Wrapped in React's cache() so the extra
round-trip happens once per request, not once per call site (several pages
call this more than once). Re-fetching the live record also means a
deactivated account (`active: false`) stops working on its very next
request instead of only being blocked at a new login.
Regression test builds the exact forged cookie used to verify this,
confirmed red against the vulnerable code and green against the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Extract prompt text into buildPolishPrompt() in polish-prompt.ts (no server-only),
matching the pattern used by batch.ts/buildBatchPrompt and narrative.ts/buildNarrativePrompt
so the prompt itself is unit testable.
- Add MAX_NOTE_LENGTH guard to truncate excessively long notes before polishing.
- Add early-return guard against empty input (non-retryable error).
- Run getProvider() and getSystemPrompt() concurrently via Promise.all().
- Document that polishNote() throws (foreground user-initiated action).
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Fix KPI row "Hours invested" caption to honestly show both session-derived and manually logged hours
- Add JSDoc warnings about manualMinutes being a discriminator (do not pass 0 for git commits)
- Extract isManual predicate to single source of truth, avoid triple derivation
- Add tests for mixed session+manual in same month and manual-only shape
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
MiniMax M2.x reasons inside <think> tags in the normal content field, and
that reasoning is billed against max_tokens. At 4096 the heavier batches
spent the entire budget thinking, were truncated before writing a single
answer, and stripThinking correctly removed the unclosed block — leaving an
empty response.
The signature was distinctive: batches returned 25/25 or 0/25, never
anything partial. 193 of 779 commits went unsummarised with no error
anywhere. At 16384 the same 193 completed 8/8 batches with zero failures.
The interview's question call had the same bug at 1024, where it would have
returned an empty question and silently ended the interview on the first
turn. Now 8192.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>