Phase 8a Task 7 shipped the headers argument as part of the
Server Action signature. Server Actions serialise their arguments
through Next's RSC protocol, and a Headers instance does not survive
that — it ends up as something with no .get() and the action
explodes with 'headers.get is not a function' the first time the
wizard runs.
Fix: drop the headers parameter from the action and read the
inbound request's headers via next/headers inside the action. The
client component no longer needs to construct a Headers instance
either.
This was caught the first time a real browser hit the wizard — the
integration tests in Task 17 mock the request and never serialised
a Headers through RSC.
Phase 8c Task 5. Server Action caps at 40000 chars on write, owner-
only via requireOwner(). Client component: one textarea, live
counter, explicit Save button (no auto-save), repo URL rendered as
a 'see also' link rather than a tool call.
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 8b Task 5. Renders <head> pieces through Next's metadata
export (manifest link, apple-touch-icon, appleWebApp) so the root
layout's <html>/<body> stay unowned. The only inline JSX is the
service-worker registration script. Verified: manifest, icon,
sw.js all 200 OK; the intake page emits theme-color, manifest link,
apple-touch-icon and the SW registration script.
Phase 8b Task 4. Cache-first for the static asset allowlist (icons,
manifest, root HTML). Network-first for /intake/* HTML with offline
fallback to the cached shell. Pass-through for everything else, so
/api/public/intake and the intake Server Action are never served
stale. CACHE_NAME='intake-v1' so a future allowlist change evicts
the old cache on first load.
Phase 8b Task 3. start_url is /intake/ (trailing slash) so the
browser falls back to whatever token was last visited, or to the
install-time URL on first open. The intake form is per-token anyway;
the developer will issue a fresh link to a client who closes and
re-opens the PWA after revocation.
Phase 8b Task 2. @resvg/resvg-js renders public/icons/source.svg
into the four PNG sizes the manifest + iOS expect: 192, 512,
maskable-512 (Android safe-zone), and apple-touch-icon-180.
Build is one-shot — outputs are committed, runtime doesn't ship
the dep.
Phase 8b Task 1. Rounded-square brand tile with a single white
exclamation mark. Colour matches src/lib/brand-default.ts. Tasks 2
or later rasterise this into the four PNGs the manifest + iOS
expect.
Phase 8a Task 18. Server Actions are exposed via Next's RSC
boundary, not as plain HTTP endpoints; reaching into them from
vitest is not worth the complexity for a single new action. These
tests assert the admin project page renders for the owner so the
absence of unit-level action coverage is explicit.
Phase 8a Task 17. Mirrors the existing intake-submit.test.ts pattern:
- happy path: card lands in inbox with created_by_role=anonymous
- counter bump: submission_count and last_submission_at update
- hostile brief: server-set fields cannot be overridden, even when
the body includes top-level priority/accepted/column/created_by
- none of unknown/revoked/expired/malformed tokens reach the
handler — they all 404 with the same wording
- deleted-project token: link cascades away, 404 follows
- activity log: card_submitted_anonymously row written
Deviation from plan: the second project needs an explicit Inbox
column created in beforeAll — the schema does not auto-create one
for new projects.
The schema the plan specified had three places where a PocketBase
quirk blocked normal use of intake_links / activity rows. None of
them surfaced in Task 1 because the unit-level test there only
checked rule application, not inserts.
1. intake_links.submission_count and submission_count_today were
required: true with min: 0. PocketBase treats 0 as blank for
required number fields, so every insert 400'd. Made optional.
2. intake_links.active was required: true, which rejects false as
blank (the same quirk). The owner can mint an already-revoked
link (the test exercises this); made optional.
3. activity.actor was a required relation. The public route writes
a card_submitted_anonymously row for a submitter who has no user
account; empty string is not a valid relation. Made optional and
dropped actor: '' from the route.
Phase 8a Task 17. Mirrors the existing intake-submit.test.ts pattern:
- happy path: card lands in inbox with created_by_role=anonymous
- counter bump: submission_count and last_submission_at update
- hostile brief: server-set fields cannot be overridden, even when
the body includes top-level priority/accepted/column/created_by
- none of unknown/revoked/expired/malformed tokens reach the
handler — they all 404 with the same wording
- deleted-project token: link cascades away, 404 follows
- activity log: card_submitted_anonymously row written
Phase 8a Task 16. The admin layout already enforces owner-only
access. Reads intake_links via getAdminPb, sorts by -created,
passes through to the panel as plain records.
Phase 8a Task 15. Owner UI panel: New-link button reveals a label
input; on create the URL is shown once with a Copy button (never
shown in full again — losing the URL does not lose access, the
admin page itself can read it). Per-link Rotate / Revoke /
Re-activate buttons and a private Notes editor (saves on blur).
Phase 8a Task 14. Server Actions are owner-only via requireOwner().
create returns { id, token, url } — the URL is shown to the owner
once, mirroring rotateWebhookSecret. rotate issues a fresh token and
invalidates the old URL immediately. setIntakeLinkActiveAction
covers revoke (active=false). Notes are capped at 1000 chars on
both write paths.
Phase 8a Task 13. CardSubmittedBy renders nothing for 'owner',
'Requested' for 'client', and 'Submitted via public link' for
'anonymous'. Renders nothing once the card is accepted. Replaces the
inline client-only check on the card detail page.
Phase 8a Task 12. Static page; no card read, no brief shown, no
link to the signed-in board. Resolves the token only to confirm it
is still alive — a revoked token landing here still shows 'Thanks'
so the submission cannot be probed.
Phase 8a Task 11. Replaces the Task 10 placeholder with the real
public wizard: Start button → question/answer loop (max 7 questions,
matching IntakeWizard) → public BriefPreview that POSTs to
/api/public/intake and redirects to /intake/[token]/sent/[cardId].
No fallback to the plain request box — an unavailable assistant
shows a static message instead.
Phase 8a Task 10. Server component 404s on unknown/revoked/expired
tokens with the same wording so the response cannot probe token
existence. Only the link's label and the project name cross into
the rendered HTML; counts, notes and the token itself stay
owner-only.
The IntakePublic component is a placeholder — Task 11 replaces it
with the real wizard. Keeping the placeholder lets the build stay
green between the two commits.
Phase 8a Task 9. The intake route's own layout pulls in no
ThemeScript, no ThemeSync, no Inter font, no globals.css — so the
public route ships without the signed-in app shell.
Deviation from plan: this layout does NOT redefine <html>/<body>.
Next.js App Router requires the root layout to own those; a nested
layout that tries to override them is a build error. The intent
(public route is its own thing, no app shell) is preserved.
Phase 8a Task 8. POST /api/public/intake is the sibling of
/api/client/intake, not a branch: same parseBrief whitelist, same
card-create code path, with created_by_role='anonymous' set in code
(not from the body). 404 for unknown/revoked/expired tokens — same
wording for all three, so the response cannot probe token existence.
Counter bumps are best-effort so a card is never lost to a
counter-update failure.
Deviation from plan: dropped the dead 'tokenHeader' line that the
plan's own comment admits is vestigial.
Phase 8a Task 7. token-shape check → rate-limit → daily-cap →
resolve. Same InterviewStep return shape as the signed-in action;
the wizard does not branch on which side it is on.
Phase 8a Task 6. The matcher's exclusion is unchanged; this is the
explicit short-circuit so a future matcher edit cannot accidentally
lock the public route out.
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.
Canonical record for Phase 8 task progress. Mirrored to the Apple
Notes note 'Phase 8 — Public intake + PWA + project context' in the
DEVELOPMENT folder; the note cannot be ticked in place from this
session because Full Disk Access is not granted to the launching
app (update-note requires it).
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.
Covers the 9 scenarios from the §6.3 plan brief, mapped to the harness's
sessionless reality:
- client propose refuses without a session and does not write fields
- rescheduleCardAction refuses a Done card with 'Card is done'
- foreign-client propose path: PocketBase read rule denies the cross-tenant
lookup (anti-enumeration per access.ts:57-59)
- accept/dismiss/returnToScheduled refuse without a session and are no-ops
- buildActivityRows collapses same-date patches into zero rows and emits one
row when the date actually changes
HARNESS-LIMIT: the actions that need an owner session (accept, dismiss,
returnToScheduled) cannot be driven successfully in vitest. They are
covered by the manual smoke checklist at the workspace path. The
sessionless refuse path proves the gate fires for every call; the
positive path is exercised by the live smoke run.
Test file: tests/integration/plan-puzzle.test.ts (single new file)
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.
logWorkAction wrote the full polished/typed message into both `summary`
and `subject`, but `subject` exists to hold a short git commit subject
line and is capped at 300 characters at the schema level. Any note over
300 characters (trivially reachable — a 2-3 sentence polished note
routinely runs 350-450 characters) failed the create call outright, and
the generic catch surfaced only "Could not save that entry" with the
real reason discoverable only by reproducing the write directly against
PocketBase.
`subject` is only ever a fallback for when `summary` is empty
(`commit.summary || commit.subject`), and a logged entry's summary is
always populated at creation — so truncating subject costs nothing
functionally. Also logs the underlying error server-side on any future
create failure, so this class of bug doesn't require re-deriving the
cause from scratch again.
Reproduced against the live PocketBase instance with the exact message
that failed for the user (348 chars) before and after the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Critical fixes to test suite:
1. Fix time bomb in summarise.test.ts: Task 4 changed summaryStatus filter to exclude source="manual",
so the assertion would fail once any manual entry exists. Updated test to count only non-manual
commits and added coverage proving manual entries are excluded.
2. Tighten log-work metrics assertions from toBeGreaterThanOrEqual to exact equality (toBe).
The old assertions would miss if manual entries were incorrectly fed into session clustering.
Use a fresh baseline recompute to catch regression of Task 2's manual exclusion fix.
3. Add recomputeMetrics call in afterAll to restore cache after test cleanup,
preventing inflated dashboard metrics on the demo project.
4. Fix fixture link_source from "none" to "manual" to match actual logWorkAction behavior
(Task 6 review fix).
5. Add UI coverage: "Log work" button is owner-only, verifying the security model.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Tests that logWorkAction refuses requests without a valid owner session
- Tests that recomputeMetrics correctly adds manual entry minutes to estimatedMinutes
- Both tests verify the integration between log-work action and metrics computation
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Create shared LoggedTag component to prevent style drift across three render paths
- Replace all three inline spans with single LoggedTag component import
- Fix dead "Show technical detail" toggle: only render when card has git commits
- Fix colliding screen-reader labels in unassigned tray by using commit subject instead of generic text
- Ensures manual entries stay visually consistent and semantically correct everywhere
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>