feat(web): an account's plan template, stored and served (plan as one document 1/12)
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(migration0087_account_plan_template, plus its journal entry and the column inserver/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 inPLAN_HEADINGorder 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-templateto 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
hostedStoreOrUnavailablewrapper 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 gets400 invalid-request.
- GET answers
-
Store:
readPlanTemplatereads only the template column.writePlanTemplateupserts the row and sets onlyplan_template. It does not touchupdated_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 itsupdatedAt, 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
nullresets 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 with400 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