@mccaskie-labs/workbench-config 0.2.5 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  // Generated by `npm run capabilities:build` from capabilities/*.json. Do not edit.
2
2
  // check:capabilities fails in Checks when this file is stale.
3
- export const MANIFEST_VERSION = "b74fc43c59b7";
3
+ export const MANIFEST_VERSION = "9aae488ad117";
4
4
  export const capabilities = {
5
5
  "ai": {
6
6
  "$schema": "./schema.json",
@@ -627,6 +627,271 @@ export const capabilities = {
627
627
  "intro": "Counting page views and the things visitors do on the site."
628
628
  }
629
629
  },
630
+ "availability": {
631
+ "$schema": "./schema.json",
632
+ "format": 1,
633
+ "component": "availability",
634
+ "title": "Availability and pricing",
635
+ "summary": "workbench-availability answers \"is this date free, and what does it cost?\" for a bookable thing (a venue, a room, a photographer's day) from its calendar and its price rules, with the same answer every time and one plain line per rule applied. A store over the app's own database stops two bookings landing on one date, and a tap-to-block calendar lets the owner close dates from a phone. It runs in the app itself, with no Gateway call, no AI and no cost.",
636
+ "status": "built",
637
+ "surfaces": {
638
+ "package": [
639
+ "quote",
640
+ "createAvailability",
641
+ "availabilityMigration",
642
+ "calendarAction",
643
+ "checkSettings",
644
+ "stayClaims",
645
+ "staysClash",
646
+ "todayIn",
647
+ "AvailabilityError"
648
+ ]
649
+ },
650
+ "rules": [
651
+ {
652
+ "id": "availability.quote-is-fact",
653
+ "kind": "must",
654
+ "text": "Take every availability answer and price the app shows or sends from quote() (or createAvailability's quote), and pass its lines and explain on as facts. Never let AI work out a price or a free date, and never write a price into a page or prompt by hand.",
655
+ "enforced_by": "review",
656
+ "probe": "availability.quote"
657
+ },
658
+ {
659
+ "id": "availability.book-through-block",
660
+ "kind": "must",
661
+ "text": "Book, hold and block dates only through createAvailability's block(), which runs availability_block() in one transaction: never insert into availability_blocks directly, so the exclusion constraint and the hold expiry always apply.",
662
+ "enforced_by": "database",
663
+ "probe": "availability.store"
664
+ },
665
+ {
666
+ "id": "availability.calendar-days",
667
+ "kind": "must",
668
+ "text": "Treat dates as calendar days (YYYY-MM-DD) in the account's time zone: pass todayIn(timeZone) as today, never new Date() in UTC.",
669
+ "enforced_by": "types",
670
+ "probe": "availability.quote"
671
+ },
672
+ {
673
+ "id": "availability.no-names-in-notes",
674
+ "kind": "must_not",
675
+ "text": "Write a guest's name or phone number into a block's note: link the app's own booking in subject instead. The .ics feed carries neither.",
676
+ "enforced_by": "review",
677
+ "probe": "availability.store"
678
+ },
679
+ {
680
+ "id": "availability.public-gets-the-quote",
681
+ "kind": "must_not",
682
+ "text": "Let a public page or the anonymous key read availability_blocks: public pages get the finished quote from the server.",
683
+ "enforced_by": "database",
684
+ "probe": "availability.store"
685
+ },
686
+ {
687
+ "id": "availability.daily-cleanup",
688
+ "kind": "must",
689
+ "text": "Run createAvailability's cleanup() once a day from the app's cron with the service client, so lapsed holds show as expired and old blocks are deleted on time.",
690
+ "enforced_by": "review",
691
+ "probe": "availability.store"
692
+ },
693
+ {
694
+ "id": "availability.calendar-as-shipped",
695
+ "kind": "must",
696
+ "text": "Draw the owner's calendar with renderCalendar and CALENDAR_SCRIPT (under the page's nonce), and hand the posted body to calendarAction behind the owner's sign-in. Never hand-write the calendar's markup or script.",
697
+ "enforced_by": "review",
698
+ "probe": "availability.calendar"
699
+ }
700
+ ],
701
+ "examples": [
702
+ {
703
+ "id": "availability.example.quote",
704
+ "title": "Answer \"is 12 Dec free, and how much?\"",
705
+ "lang": "ts",
706
+ "code": "import { createAvailability } from '@mccaskie-labs/workbench-availability'\nimport settings from '../workbench/availability.json'\n\nconst av = createAvailability({ db: serviceClient, account: venue.id, settings, country: 'PH', timeZone: 'Asia/Manila' })\nconst q = await av.quote({ resource: 'villa-sol-pool', date: '2026-12-12', stay: 'day_use', guests: { min: 15, max: 15 }, eventType: 'birthday' })\n// q.available 'yes'; q.options[0].total { amount: '9500.00', currency: 'PHP' }; q.explain ['Weekend day use: ₱9,500 (rule r-weekend)', ...]",
707
+ "probe": "availability.quote"
708
+ },
709
+ {
710
+ "id": "availability.example.calendar",
711
+ "title": "The owner's tap-to-block calendar",
712
+ "lang": "ts",
713
+ "code": "import { calendarAction } from '@mccaskie-labs/workbench-availability'\nimport { CALENDAR_SCRIPT, renderCalendar } from '@mccaskie-labs/workbench-availability/calendar'\n\nconst days = await av.month('villa-sol-pool', '2026-12')\nconst html = renderCalendar(days, t, { resource: 'villa-sol-pool', month: '2026-12', endpoint: '/owner/calendar', stays: [{ id: 'day_use', label: t('ui.stay.day_use')! }] })\n// The page adds <script nonce=...>${CALENDAR_SCRIPT}</script>; POST /owner/calendar (owner signed in):\nconst r = await calendarAction(av, await request.json())\nreturn Response.json(r.body, { status: r.status })",
714
+ "probe": "availability.calendar"
715
+ }
716
+ ],
717
+ "probes": [
718
+ {
719
+ "id": "availability.quote",
720
+ "half": "runtime",
721
+ "expect": "pass",
722
+ "smoke": true,
723
+ "cost_hint_usd": 0,
724
+ "note": "In the Worker itself: quote() on the spec's HmPo example (Sat 12 Dec ₱9,500, Sat 19 Dec ₱10,925 with peak, 25 guests ₱10,250 over capacity, no guests a from price) and the alternatives when 19 Dec is booked; no database, no Gateway call"
725
+ },
726
+ {
727
+ "id": "availability.store",
728
+ "half": "runtime",
729
+ "expect": "pass",
730
+ "cost_hint_usd": 0,
731
+ "note": "Against the probe app's own database: two block(booked) calls for one night at once (one lands, one refused as clash), a lapsed hold quoted free and marked expired by cleanup(), another account's resource unreachable, then every probe block removed"
732
+ },
733
+ {
734
+ "id": "availability.calendar",
735
+ "half": "runtime",
736
+ "expect": "pass",
737
+ "cost_hint_usd": 0,
738
+ "note": "The probe page draws a month with renderCalendar and CALENDAR_SCRIPT: tap, Block day, Undo leaves no block; a change made offline shows \"1 change waiting\" and is sent on reconnect"
739
+ }
740
+ ],
741
+ "offer": {
742
+ "pattern": "settings",
743
+ "when": "The client takes bookings for something with dates: a venue, a room, a resort day pass, a photographer's or hire business's days, or wants prices that depend on the day, season, holiday, number of guests or kind of event, or wants a calendar to block dates.",
744
+ "say": "I'll set this up with Workbench availability, so every price and free date comes out the same way each time, explained line by line, and you get a calendar to block dates from your phone. Here's how a business like yours usually books; tell me what to change.",
745
+ "answers_file": "workbench/availability.json",
746
+ "answers_format": "`{ \"format\": 1, \"status\": \"draft\" | \"done\", \"what\": \"<the client's words>\", \"stay_types\": [{ \"id\", \"start\", \"end\", \"clashes\" }], \"event_types\": [...], \"currency\": \"PHP\", \"alternatives\": { \"count\": 3, \"within_days\": 21 }, \"hold_days\": 3, \"holidays\": \"weekend\" | \"weekday\" | \"separate\" }`. Every stay type clashes with itself; list each pair that can't share a date on both sides. Take `currency` from workbench/country.json. checkSettings() reads this file at startup and names every problem. Each account's resources, packages and price rules are data in the app's database (availability_resources, availability_packages, availability_price_rules), entered on the owner's pages, never in this file. Copy availabilityMigration into supabase/migrations with its down file, and CALENDAR_WORDS into the strings catalogue.",
747
+ "questions": [
748
+ {
749
+ "id": "availability.what",
750
+ "sets": "availability.what",
751
+ "ask": "What do people book?",
752
+ "style": "table",
753
+ "other": {
754
+ "kind": "text",
755
+ "min": 3,
756
+ "max": 200,
757
+ "description": "In the client's words, such as \"a resort's pool and function hall\" or \"me, as a photographer, by the half or full day\"."
758
+ },
759
+ "must_ask": true,
760
+ "note": "Only the client knows. Their answer names the resources (one per thing booked separately) and suggests the stay types to propose next."
761
+ },
762
+ {
763
+ "id": "availability.stays",
764
+ "sets": "availability.stay_types",
765
+ "ask": "Here are the usual ways it's booked in a day, with their hours and which can't share a date. Accept them, change some, or add or remove?",
766
+ "style": "table",
767
+ "other": {
768
+ "kind": "list",
769
+ "min": 1,
770
+ "max": 10,
771
+ "description": "Each way it's booked, with its start and end time and the others it can't share a date with."
772
+ },
773
+ "must_ask": true,
774
+ "note": "Propose from the client's answer to availability.what. A resort or venue: day use 08:00 to 17:00, night use 19:00 to 07:00, overnight 14:00 to 12:00 the next day (overnight clashes with both; day and night use don't clash with each other). A photographer or hire business: half day and full day, clashing with each other. One room or slot a day: a single stay type."
775
+ },
776
+ {
777
+ "id": "availability.events",
778
+ "sets": "availability.event_types",
779
+ "ask": "Should prices depend on the kind of event? Here is the usual list.",
780
+ "style": "cards",
781
+ "answers": [
782
+ {
783
+ "label": "Yes, use this list",
784
+ "value": "list",
785
+ "means": "Birthday, debut, wedding, baptism, reunion, team building, other: a rule can add a fee for some of them."
786
+ },
787
+ {
788
+ "label": "No, same price for all",
789
+ "value": "none",
790
+ "means": "No event types; the quote never asks for one."
791
+ },
792
+ {
793
+ "label": "Yes, with my own list",
794
+ "value": "own",
795
+ "means": "Type the kinds of event the business prices differently."
796
+ }
797
+ ],
798
+ "other": {
799
+ "kind": "list",
800
+ "min": 1,
801
+ "max": 20,
802
+ "description": "The kinds of event, each a short name."
803
+ },
804
+ "note": "Recommend the usual list for venues and resorts; \"none\" for most other businesses. Write ids in lower case with underscores (team_building)."
805
+ },
806
+ {
807
+ "id": "availability.holidays",
808
+ "sets": "availability.holidays",
809
+ "ask": "Should holidays be priced like weekends?",
810
+ "style": "cards",
811
+ "answers": [
812
+ {
813
+ "label": "Yes, like a weekend",
814
+ "value": "weekend",
815
+ "means": "A regular holiday or special non-working day on a weekday costs the weekend price."
816
+ },
817
+ {
818
+ "label": "No, by the day of the week",
819
+ "value": "weekday",
820
+ "means": "Holidays cost what their day of the week costs."
821
+ },
822
+ {
823
+ "label": "Set holiday prices separately",
824
+ "value": "separate",
825
+ "means": "Holidays get their own price rules, entered on the owner's pages."
826
+ }
827
+ ],
828
+ "other": {
829
+ "kind": "text",
830
+ "min": 3,
831
+ "max": 200,
832
+ "description": "How holidays should be priced, in the client's words."
833
+ },
834
+ "note": "Holidays come from workbench-country's yearly list (the Philippines' proclamation each year); a local fiesta goes in the availability_holidays table. Special working days (the EDSA anniversary) are never priced as holidays."
835
+ },
836
+ {
837
+ "id": "availability.holds",
838
+ "sets": "availability.hold_days",
839
+ "ask": "How long is a date held while waiting for a deposit?",
840
+ "style": "cards",
841
+ "answers": [
842
+ {
843
+ "label": "3 days",
844
+ "value": 3,
845
+ "means": "The date shows as held (H) for 3 days, then is free again unless marked booked."
846
+ },
847
+ {
848
+ "label": "1 day",
849
+ "value": 1
850
+ },
851
+ {
852
+ "label": "7 days",
853
+ "value": 7
854
+ },
855
+ {
856
+ "label": "No holds",
857
+ "value": 0,
858
+ "means": "A date is free until it is booked; nothing is held."
859
+ }
860
+ ],
861
+ "other": {
862
+ "kind": "whole",
863
+ "min": 0,
864
+ "max": 60,
865
+ "description": "Days, 0 to 60."
866
+ }
867
+ }
868
+ ],
869
+ "not_asked": [
870
+ {
871
+ "sets": "availability.prices",
872
+ "why": "Each account's prices, packages and rules change often, so they are data the owner enters on their own pages (or that onboarding reads from their rate card), not project settings."
873
+ },
874
+ {
875
+ "sets": "availability.alternatives",
876
+ "why": "Up to 3 nearest free dates within 21 days, same weekday first, suits every business so far; change it in the answers file if a client asks."
877
+ },
878
+ {
879
+ "sets": "availability.currency",
880
+ "why": "It is the country's own, from workbench/country.json."
881
+ }
882
+ ],
883
+ "needs": [
884
+ "country"
885
+ ]
886
+ },
887
+ "styles": {
888
+ "prefix": "wb-cal-"
889
+ },
890
+ "delivery": {
891
+ "choice": "optional",
892
+ "intro": "Which dates are free and what each costs, worked out the same way every time, with a calendar the owner taps to block dates."
893
+ }
894
+ },
630
895
  "config": {
631
896
  "$schema": "./schema.json",
632
897
  "format": 1,
@@ -781,7 +1046,11 @@ export const capabilities = {
781
1046
  "currencySymbol",
782
1047
  "currencySymbolFrom",
783
1048
  "WORLDWIDE",
784
- "isWorldwide"
1049
+ "isWorldwide",
1050
+ "HOLIDAYS",
1051
+ "holidaysFor",
1052
+ "holidayOn",
1053
+ "checkHolidays"
785
1054
  ]
786
1055
  },
787
1056
  "rules": [
@@ -806,6 +1075,13 @@ export const capabilities = {
806
1075
  "enforced_by": "types",
807
1076
  "probe": "country.packs"
808
1077
  },
1078
+ {
1079
+ "id": "country.holidays-from-proclamation",
1080
+ "kind": "must",
1081
+ "text": "Take public holidays from holidaysFor or holidayOn, never worked out or guessed: a year with no list returns undefined, and checkHolidays fails from 1 December until next year's list is in.",
1082
+ "enforced_by": "review",
1083
+ "probe": "country.holidays"
1084
+ },
809
1085
  {
810
1086
  "id": "country.spelling-from-pack",
811
1087
  "kind": "must",
@@ -838,6 +1114,14 @@ export const capabilities = {
838
1114
  "expect": "pass",
839
1115
  "cost_hint_usd": 0,
840
1116
  "note": "Every pack's spelling is one the Gateway accepts (en-PH, en-AU, en-GB, en-US), checked against the ai manifest's spellings; no Gateway call"
1117
+ },
1118
+ {
1119
+ "id": "country.holidays",
1120
+ "half": "runtime",
1121
+ "expect": "pass",
1122
+ "smoke": true,
1123
+ "cost_hint_usd": 0,
1124
+ "note": "The December probe: checkHolidays for every country with a built-in list, on today in that country's time zone. Fails when this year's list is missing, and from 1 December when next year's is, so the list is added from the proclamation before January's prices need it"
841
1125
  }
842
1126
  ],
843
1127
  "offer": {
@@ -1238,10 +1522,39 @@ export const capabilities = {
1238
1522
  "note": "On a project's staging: cf_turnstile_widget_create and the turnstile_sitekey and turnstile_secret sources; the sign-in page shows the check, /auth/code without a passed token sends no code, and with one it does"
1239
1523
  }
1240
1524
  ],
1525
+ "offer": {
1526
+ "pattern": "settings",
1527
+ "when": "The app needs to store or read data, or the client asks for a change to what is stored: a new table, column, rule or migration.",
1528
+ "say": "The app's data goes in its own Workbench database, one for testing and one for the live site. I'll propose what to store and who can see it, and change it with a migration you can undo.",
1529
+ "answers_file": "workbench/database.json",
1530
+ "answers_format": "`{ \"format\": 1, \"status\": \"draft\", \"tables\": [{ \"name\": \"orders\", \"row\": \"one order\", \"columns\": [{ \"name\": \"total\", \"kind\": \"money\", \"required\": true }], \"read\": \"own\", \"change\": \"own\" }] }`. Then write one migration (`supabase/migrations/<14 digits>_<name>.sql`) with its down file, turning on row-level security with one policy per answer, apply it on staging with Workbench Control (`sb_migration_apply`), and refresh `supabase/staging-columns.json`.",
1531
+ "questions": [
1532
+ {
1533
+ "id": "database.tables",
1534
+ "sets": "tables",
1535
+ "ask": "What should the app store, and who can see and change it?",
1536
+ "style": "table",
1537
+ "note": "Propose the tables the request needs, each with what one row is, its columns (name, kind such as text, number, money, date, yes/no, a link to another table; and whether it is required), and who may read and who may change its rows: `own` (the person who made the row), `admins`, `signed_in` (anyone signed in) or `public` (anyone, read only). Recommend `own` for anything about a person. Show money as whole cents, never a fraction. The client accepts, removes or adds tables and columns."
1538
+ }
1539
+ ],
1540
+ "not_asked": [
1541
+ {
1542
+ "sets": "keys",
1543
+ "why": "The app reaches the database as the signed-in person (`dbAs`), so row-level security decides what each person sees; the template holds no secret key."
1544
+ },
1545
+ {
1546
+ "sets": "hosting",
1547
+ "why": "Each project has its own staging and production databases, made by Workbench Control in the region set for the install."
1548
+ },
1549
+ {
1550
+ "sets": "sign_in_settings",
1551
+ "why": "The database's sign-in settings follow the Workbench baseline (the workbench-sign-in skill)."
1552
+ }
1553
+ ]
1554
+ },
1241
1555
  "delivery": {
1242
1556
  "choice": "built_in",
1243
- "intro": "The app's own database, one for testing and one for the live site.",
1244
- "when": "The app needs to store or read data, or the client asks for a change to what is stored: a new table, column, rule or migration."
1557
+ "intro": "The app's own database, one for testing and one for the live site."
1245
1558
  }
1246
1559
  },
1247
1560
  "email": {
@@ -1546,13 +1859,77 @@ export const capabilities = {
1546
1859
  "note": "GET brand.json's logo.url from outside with no sign-in answers 200 with an image content type"
1547
1860
  }
1548
1861
  ],
1862
+ "offer": {
1863
+ "pattern": "settings",
1864
+ "when": "The app needs to send an email: a confirmation, receipt, notification, invitation or sign-in code.",
1865
+ "say": "I'll send these with Workbench email, which sends from the app's own address in your brand, with no email key in the app. The sign-in codes are already there; I'll add yours beside them.",
1866
+ "answers_file": "workbench/email.json",
1867
+ "answers_format": "`{ \"format\": 1, \"status\": \"draft\", \"reply_to\": null, \"emails\": [{ \"id\": \"order_confirmed\", \"when\": \"a customer sends an order\", \"to\": \"the customer\", \"parts\": [\"heading\", \"paragraph\", \"button\"], \"link_changes_something\": false }] }`. Declare each email in `src/lib/emails.ts` with `defineEmails` (stream `transactional`, shell `account`, `decidedBy` naming this answers file), put its words in the strings under `email.<id>.<key>`, and send it with `mailerFor(env)` and an `idempotencyKey` whenever a retry could repeat it.",
1868
+ "questions": [
1869
+ {
1870
+ "id": "email.emails",
1871
+ "sets": "emails",
1872
+ "ask": "Which emails should the app send?",
1873
+ "style": "table",
1874
+ "note": "Propose the emails the request needs, each with an id (snake_case: `order_confirmed`), when it is sent, who gets it, its parts in order (heading, paragraphs, a button and its link, small print) and whether its link changes something (then the link lands on a confirmation page first). Only emails a person's own action or account causes: receipts, confirmations, notices. The client accepts, removes or adds. The wording is drafted afterwards, one email at a time."
1875
+ },
1876
+ {
1877
+ "id": "email.reply-to",
1878
+ "sets": "reply_to",
1879
+ "ask": "Where should a reply to these emails go?",
1880
+ "style": "cards",
1881
+ "answers": [
1882
+ {
1883
+ "label": "Nowhere",
1884
+ "value": null,
1885
+ "means": "The emails come from an address that isn't read. Say so in the small print, with where to get help instead."
1886
+ },
1887
+ {
1888
+ "label": "To the app",
1889
+ "value": "app",
1890
+ "means": "Replies go to an address the app reads itself (Workbench inbound email)."
1891
+ }
1892
+ ],
1893
+ "other": {
1894
+ "kind": "text",
1895
+ "min": 6,
1896
+ "max": 254,
1897
+ "description": "An email address someone at the business reads."
1898
+ },
1899
+ "must_ask": true,
1900
+ "note": "A typed address goes in each send as `replyTo`; check it looks like an email address. With the app, the workbench-email-inbound skill sets up the address."
1901
+ }
1902
+ ],
1903
+ "not_asked": [
1904
+ {
1905
+ "sets": "sender",
1906
+ "why": "Emails come from the project's own sending address (`FROM` in `src/lib/email.ts`) on its own domain, which Workbench Control sets up."
1907
+ },
1908
+ {
1909
+ "sets": "marketing",
1910
+ "why": "Only transactional email exists: newsletters and other bulk mail are refused until Workbench has a sender for them, so offer none."
1911
+ },
1912
+ {
1913
+ "sets": "look",
1914
+ "why": "Emails take the brand's name, colour and logo, and the project's `project.css` (see Restyling)."
1915
+ }
1916
+ ],
1917
+ "drafts": {
1918
+ "of": "wording for one email",
1919
+ "surface": "ai",
1920
+ "alias": "claude-default",
1921
+ "count": 3,
1922
+ "max_rounds": 3,
1923
+ "job": "ai.drafts",
1924
+ "command": "npx workbench-ai drafts text email_<id> --about \"<what the email is for, who gets it and its parts>\""
1925
+ }
1926
+ },
1549
1927
  "styles": {
1550
1928
  "prefix": "wb-email-"
1551
1929
  },
1552
1930
  "delivery": {
1553
1931
  "choice": "built_in",
1554
- "intro": "The emails the app sends, such as confirmations, receipts and sign-in codes.",
1555
- "when": "The app needs to send an email: a confirmation, receipt, notification, invitation or sign-in code."
1932
+ "intro": "The emails the app sends, such as confirmations, receipts and sign-in codes."
1556
1933
  }
1557
1934
  },
1558
1935
  "email-inbound": {
@@ -1682,10 +2059,54 @@ export const capabilities = {
1682
2059
  "note": "cf_email_routing_rule_delete returns a confirmation and changes nothing until Robert approves"
1683
2060
  }
1684
2061
  ],
2062
+ "offer": {
2063
+ "pattern": "switch_on",
2064
+ "when": "The client wants the app to receive email: replies to its messages, or an address such as hello@ that the app handles.",
2065
+ "say": "The project can already receive email with Workbench inbound email: each address goes straight to the app, which decides what to do with every message. I'll switch on the addresses you want.",
2066
+ "answers_file": "workbench/email-inbound.json",
2067
+ "answers_format": "`{ \"format\": 1, \"addresses\": [{ \"name\": \"hello\", \"does\": \"saves it as a message the admins see\" }] }`. Then set `INBOUND_EMAIL` to `\"on\"` in `wrangler.jsonc`, write `handleEmail` in `src/app.ts` to do what each address `does`, and make the addresses with Workbench Control: `cf_email_routing_enable`, then `cf_email_routing_rule_create` for each address on staging first (the capabilities guide below has the steps).",
2068
+ "questions": [
2069
+ {
2070
+ "id": "email-inbound.addresses",
2071
+ "sets": "addresses",
2072
+ "ask": "Which addresses should the app receive, and what should it do with each message?",
2073
+ "style": "table",
2074
+ "note": "Propose the addresses the request needs, each with its name (the part before @, lower case: `hello`, `bookings`) and what the app does with a message, in the client's words: save it for the admins to read, add it to a booking, answer it automatically. One address, one job. Say plainly that the app reads the mail itself: nothing is forwarded to anyone's inbox."
2075
+ },
2076
+ {
2077
+ "id": "email-inbound.attachments",
2078
+ "sets": "attachments",
2079
+ "ask": "What should happen to files attached to these emails?",
2080
+ "style": "cards",
2081
+ "answers": [
2082
+ {
2083
+ "label": "Leave them out",
2084
+ "value": "drop",
2085
+ "means": "The app keeps the words of each email, not its files. Nothing extra to store or check."
2086
+ },
2087
+ {
2088
+ "label": "Keep them",
2089
+ "value": "keep",
2090
+ "means": "The app stores each file for the admins. Files can be large or unsafe, so keep only the types the job needs."
2091
+ }
2092
+ ],
2093
+ "note": "With keep, ask which types (photos, PDFs) and store them outside the Worker, in file storage the app already has, or ask the owner before adding some; refuse other types with `reject`."
2094
+ }
2095
+ ],
2096
+ "not_asked": [
2097
+ {
2098
+ "sets": "domain",
2099
+ "why": "Addresses are on the project's own email domain: its staging name first, and the client's own domain once it is attached for production (`cf_zone_attach`)."
2100
+ },
2101
+ {
2102
+ "sets": "forwarding",
2103
+ "why": "Workbench never forwards mail outside the project or catches every address: each literal address goes to the app."
2104
+ }
2105
+ ]
2106
+ },
1685
2107
  "delivery": {
1686
2108
  "choice": "optional",
1687
- "intro": "Receiving email at the project's own addresses, for the app to act on.",
1688
- "when": "The client wants the app to receive email: replies to its messages, or an address such as hello@ that the app handles."
2109
+ "intro": "Receiving email at the project's own addresses, for the app to act on."
1689
2110
  }
1690
2111
  },
1691
2112
  "email-sending": {
@@ -3610,10 +4031,53 @@ export const capabilities = {
3610
4031
  "note": "The app's own checkStrings run passes in its tests, and npx workbench-strings check passes in CI"
3611
4032
  }
3612
4033
  ],
4034
+ "offer": {
4035
+ "pattern": "project",
4036
+ "when": "The client wants to add or change wording on a page or in an email, or asks about showing the app in another language.",
4037
+ "say": "Every word the app shows is kept in one place with Workbench strings, so changing wording never means hunting through code, and other languages can be added later.",
4038
+ "answers_file": "workbench/strings.json",
4039
+ "answers_format": "`{ \"format\": 1, \"languages\": [\"en-AU\"], \"later\": [\"fil\"] }`: the language the site is written in first, and any the client wants later. New wording goes into `strings/strings.<base>.json` with one key per sentence, and `npm run check:strings` checks it.",
4040
+ "questions": [
4041
+ {
4042
+ "id": "strings.languages",
4043
+ "sets": "languages",
4044
+ "ask": "Does the site need to be in any language besides English?",
4045
+ "style": "cards",
4046
+ "answers": [
4047
+ {
4048
+ "label": "English only",
4049
+ "value": "none",
4050
+ "means": "Every page and email is in English."
4051
+ },
4052
+ {
4053
+ "label": "Another language later",
4054
+ "value": "later",
4055
+ "means": "English now. The words are kept so a translation can be added, checked by a native speaker, without changing the code."
4056
+ }
4057
+ ],
4058
+ "other": {
4059
+ "kind": "text",
4060
+ "min": 2,
4061
+ "max": 120,
4062
+ "description": "The languages, such as Filipino or Spanish."
4063
+ },
4064
+ "note": "Translations come with a Workbench tool that isn't built yet: record the languages under `later`, keep every word in the catalogue, and tell the client a translation goes live only once a named native speaker has checked it."
4065
+ }
4066
+ ],
4067
+ "not_asked": [
4068
+ {
4069
+ "sets": "base",
4070
+ "why": "The site is written first in English, in the readers' spelling from the country answers, so it isn't asked again."
4071
+ },
4072
+ {
4073
+ "sets": "wording",
4074
+ "why": "Wording is content, not a setting: the client says what they want in their own words, or asks for drafts (the workbench-ai skill), and it goes into the catalogue."
4075
+ }
4076
+ ]
4077
+ },
3613
4078
  "delivery": {
3614
4079
  "choice": "built_in",
3615
- "intro": "The words on the app's screens and in its emails, kept in one place for each language.",
3616
- "when": "The client wants to add or change wording on a page or in an email, or asks about showing the app in another language."
4080
+ "intro": "The words on the app's screens and in its emails, kept in one place for each language."
3617
4081
  }
3618
4082
  },
3619
4083
  "ui": {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- export declare const COMPONENTS: readonly ["gateway", "ai", "images", "image-search", "config", "email", "email-inbound", "email-sending", "database", "workers", "domains", "dns", "analytics", "country", "pdf", "access", "r2", "kv", "github", "strings", "ui", "forms", "sign-in"];
2
+ export declare const COMPONENTS: readonly ["gateway", "ai", "images", "image-search", "config", "email", "email-inbound", "email-sending", "database", "workers", "domains", "dns", "analytics", "country", "pdf", "access", "r2", "kv", "github", "strings", "ui", "forms", "sign-in", "availability"];
3
3
  export type Component = (typeof COMPONENTS)[number];
4
4
  export declare const allowedSchemas: {
5
5
  gateway: z.ZodObject<{
@@ -163,6 +163,7 @@ export declare const capabilitySchema: z.ZodObject<{
163
163
  access: "access";
164
164
  ai: "ai";
165
165
  analytics: "analytics";
166
+ availability: "availability";
166
167
  config: "config";
167
168
  country: "country";
168
169
  database: "database";
@@ -294,6 +295,7 @@ export declare const capabilitySchema: z.ZodObject<{
294
295
  access: "access";
295
296
  ai: "ai";
296
297
  analytics: "analytics";
298
+ availability: "availability";
297
299
  config: "config";
298
300
  country: "country";
299
301
  database: "database";
@@ -391,17 +393,10 @@ export declare const capabilitySchema: z.ZodObject<{
391
393
  optional: "optional";
392
394
  }>;
393
395
  intro: z.ZodString;
394
- when: z.ZodOptional<z.ZodString>;
395
396
  }, z.core.$strict>>;
396
397
  }, z.core.$strict>;
397
398
  export type Capability = z.infer<typeof capabilitySchema>;
398
399
  export type Offer = z.infer<typeof offer>;
399
- /**
400
- * Components whose client questions are still being written (Robert,
401
- * 11 November 2026: every shared tool ships its questions). The list only
402
- * shrinks; a new component can't join it.
403
- */
404
- export declare const OFFER_PENDING: readonly ["database", "email", "email-inbound", "strings"];
405
400
  export interface ManifestProblem {
406
401
  file: string;
407
402
  check: "schema" | "proof" | "ids" | "surfaces" | "errors" | "generated";