feat(web): an account's plan template, stored and served (plan as one document 1/12)

Merged
DA
7d ago
+676-22

PR 1 of 12 in the plan-as-one-document rollout. Plans are moving from fixed template fields to one free Markdown document, each started from the account's own starter template, which can be edited in Settings. This PR adds only the template's server side and has no effect on plans yet. Nothing reads the template to create a plan until the switch (PR 8).

What changes

  • Storage: adds account_preference.plan_template text NULL (migration 0087_account_plan_template, plus its journal entry and the column in server/db/preferences-schema.ts). Null means the built-in default. The change only adds a nullable column, so no expand/contract is needed.

  • Default and wire: adds @sidecar/hosted/plan-document-template, which exports:

    • DEFAULT_PLAN_TEMPLATE: today's eleven sections in PLAN_HEADING order as ## headings, each followed by one plain guidance sentence. Alternatives and Plan of attack are marked optional in their sentence.
    • PLAN_TEMPLATE_BOUNDS = { MAX_CHARS: 20_000 }
    • planTemplateAnswerSchema { template, custom }
    • planTemplateWriteRequestSchema { template: string | null }

    Both schemas have JSON-schema goldens.

  • Route: adds GET/PUT /api/account/plan-template to the account group (server/account-app.ts, routes/account/plan-template.ts, HOSTED_SERVICE_PATH.ACCOUNT_PLAN_TEMPLATE, regenerated rewrite table).

    • GET answers { template: stored ?? DEFAULT_PLAN_TEMPLATE, custom: stored !== null }.
    • PUT stores a string exactly as sent (not trimmed, empty allowed), or goes back to the default on null. It answers the same shape as GET.
    • Bearer resolution, refusals, the hostedStoreOrUnavailable wrapper and method-not-allowed all work the same way as on the preferences endpoint. A body with an unknown key or a template over the length limit gets 400 invalid-request.
  • Store: readPlanTemplate reads only the template column. writePlanTemplate upserts the row and sets only plan_template. It does not touch updated_at, because the desktop treats that timestamp as "the preferences part was written" and would apply the snapshot over its own settings. So a template write leaves the preferences snapshot, including its updatedAt, exactly as it was. A row that a template write creates has no preferences snapshot.

  • Docs: short additions to docs/PLANNING.md ("The fixed template") and the server README's account group section.

No desktop client yet: the Settings page (PR 7) will add the client → gateway → act chain.

Tests

apps/web/tests/account-plan-template.test.ts runs the account group with the real store on PGlite and covers:

  • GET with nothing stored answers the default with custom: false
  • PUT then GET answers the template exactly as sent: whitespace-edged text, an empty string, and a template at the limit
  • PUT null resets to the default
  • a template write leaves the preferences snapshot (voice, coding agent and updatedAt) byte-identical, and an account that never synced still gets no snapshot
  • a template over the limit, unknown keys, the wrong type, {} and a non-object body are each refused with 400 invalid-request, and nothing is stored
  • another account's template is never read
  • no bearer or an unknown bearer gets 401
  • POST and DELETE get 405

./scripts/check.sh exits 0.

This PR touches only web and the hosted package, with no Mac or UI change, so verify.sh doesn't apply.

šŸ¤– Generated with Claude Code


Devin Review