@mccaskie-labs/workbench-config 0.3.0 → 0.3.2

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 = "de365af27674";
3
+ export const MANIFEST_VERSION = "4a52a6ba9520";
4
4
  export const capabilities = {
5
5
  "ai": {
6
6
  "$schema": "./schema.json",
@@ -25,7 +25,9 @@ export const capabilities = {
25
25
  "checkWriting",
26
26
  "writingStops",
27
27
  "writingNotes",
28
- "writingFixNote"
28
+ "writingFixNote",
29
+ "Ai.reply",
30
+ "checkGuide"
29
31
  ]
30
32
  },
31
33
  "config": [
@@ -197,6 +199,13 @@ export const capabilities = {
197
199
  "text": "Name a Workbench voice and spelling with the voice and spelling options, so the Gateway adds the text, or bring the app's own voice. Never paste Workbench's voice text into a prompt.",
198
200
  "enforced_by": "gateway",
199
201
  "probe": "ai.voice"
202
+ },
203
+ {
204
+ "id": "ai.reply-facts-from-code",
205
+ "kind": "must",
206
+ "text": "Draft replies with ai.reply from facts worked out by code (prices and dates from quote(), never from the AI), and show the draft in renderReplyBox for a person to check and copy. Never send a draft automatically, and never let a draft with needs_human_check go out unread.",
207
+ "enforced_by": "review",
208
+ "probe": "ai.reply"
200
209
  }
201
210
  ],
202
211
  "examples": [
@@ -213,6 +222,13 @@ export const capabilities = {
213
222
  "lang": "ts",
214
223
  "code": "const r = await ai.text({ model: 'claude-default', job: 'article.write', voice: 'conversational', spelling: 'en-AU', prompt })\nconst issues = checkWriting(r.text, { voice: 'conversational' })\nif (writingStops(issues).length > 0) await retryWith(prompt + writingFixNote(issues))",
215
224
  "probe": "ai.voice"
225
+ },
226
+ {
227
+ "id": "ai.example.reply",
228
+ "title": "Draft a reply to an enquiry",
229
+ "lang": "ts",
230
+ "code": "import { REPLY_GUIDES } from './replies.generated'\nconst ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env), url: env.WORKBENCH_GATEWAY_URL, replyGuides: REPLY_GUIDES })\nconst q = quote(settings, ask)\nconst draft = await ai.reply({\n guide: 'venue-quote',\n facts: { venue_name: venue.name, date: ask.date, available: q.available, packages: q.options.map((o) => ({ name: o.package, price: o.total.amount })), alternatives: q.alternatives.map((a) => a.date) },\n incoming: { text, intake: read },\n language: account.reply_language,\n})\nreturn html(renderReplyBox(draft, { againAction: `/enquiries/${id}/reply` }))",
231
+ "probe": "ai.reply"
216
232
  }
217
233
  ],
218
234
  "probes": [
@@ -292,20 +308,26 @@ export const capabilities = {
292
308
  "half": "runtime",
293
309
  "expect": "pass",
294
310
  "cost_hint_usd": 0.008
311
+ },
312
+ {
313
+ "id": "ai.reply",
314
+ "half": "runtime",
315
+ "expect": "pass",
316
+ "cost_hint_usd": 0.005
295
317
  }
296
318
  ],
297
319
  "offer": {
298
320
  "pattern": "feature_or_content",
299
- "when": "The client wants the app to write, summarise, sort, answer questions or pull details out of text with AI, as a feature visitors use, or wants wording for the site written once.",
321
+ "when": "The client wants the app to write, summarise, sort, answer questions or pull details out of text with AI, as a feature visitors use, or wants wording for the site written once, or wants ready-to-paste replies to the messages the business gets (quote requests, booking questions).",
300
322
  "say": "I'll do this with Workbench AI, which uses Claude through Workbench, so the app holds no AI key and spending has a monthly cap.",
301
323
  "answers_file": "workbench/ai/<name>.json",
302
324
  "per": "feature or piece of wording",
303
- "answers_format": "For a feature: `{ \"format\": 1, \"route\": \"feature\", \"name\", \"about\" (the client's words), \"who\", \"cap_usd\", \"keep\", \"job\" }`, where `job` is the `X-Workbench-Job` the feature's calls send (`<name>` with dots, such as `quotes.estimate`), so its spending shows on its own line in Control. Write it, then build the feature with the package. For wording made once: `npx workbench-ai pick <name> <round>-<draft>` writes it from the draft the client picked, with the round's request ids and cost; never write one from nothing. Then put the wording into the strings catalogue (the workbench-strings skill), keyed for where it is shown. Drafts and their previews stay in `workbench/ai/drafts/`, which isn't committed.",
325
+ "answers_format": "For a feature: `{ \"format\": 1, \"route\": \"feature\", \"name\", \"about\" (the client's words), \"who\", \"cap_usd\", \"keep\", \"job\" }`, where `job` is the `X-Workbench-Job` the feature's calls send (`<name>` with dots, such as `quotes.estimate`), so its spending shows on its own line in Control. Write it, then build the feature with the package. For wording made once: `npx workbench-ai pick <name> <round>-<draft>` writes it from the draft the client picked, with the round's request ids and cost; never write one from nothing. Then put the wording into the strings catalogue (the workbench-strings skill), keyed for where it is shown. Drafts and their previews stay in `workbench/ai/drafts/`, which isn't committed. For replies: write the guide itself to `workbench/replies/<name>.json` (format 1: `name`, `version`, `voice`, `spelling`, `max_words`, `max_questions`, `facts` as a small JSON Schema with `x-money` on amounts and `format: date` on dates, `ask_order`, `guide` lines, `examples`, `never`), then run `npx workbench-ai replies generate`, which checks it and writes `src/replies.generated.ts`; never edit that file. Offer samples with `npx workbench-ai replies drafts <name> --languages <the client's languages>` (two to four, with the development key; tell the client the cost it prints) and keep the one the client picks with `npx workbench-ai replies pick <name> <round>-<draft>`, which makes it the guide's first example.",
304
326
  "questions": [
305
327
  {
306
328
  "id": "ai.route",
307
329
  "sets": "route",
308
- "ask": "Is this something visitors use on the site, or something made once for the site?",
330
+ "ask": "Is this something visitors use on the site, something made once for the site, or replies to the messages you get?",
309
331
  "style": "cards",
310
332
  "answers": [
311
333
  {
@@ -317,10 +339,15 @@ export const capabilities = {
317
339
  "label": "Made once for the site",
318
340
  "value": "content",
319
341
  "means": "Wording for a page, written once and kept: an About page, a welcome, product descriptions."
342
+ },
343
+ {
344
+ "label": "Replies to messages",
345
+ "value": "reply",
346
+ "means": "A ready-to-paste reply to a customer's message, with your prices and dates filled in from your own details. A person checks it and copies it; nothing is sent by itself."
320
347
  }
321
348
  ],
322
349
  "must_ask": true,
323
- "note": "Ask it even when the client's words seem to say, as a quick check: the two routes are built and paid for differently."
350
+ "note": "Ask it even when the client's words seem to say, as a quick check: the two routes are built and paid for differently. Replies are their own route: the facts (prices, dates) come from the app's own code, so ask where those facts come from before anything else."
324
351
  },
325
352
  {
326
353
  "id": "ai.who",
@@ -453,6 +480,196 @@ export const capabilities = {
453
480
  ]
454
481
  },
455
482
  "note": "Name the piece after where it goes (about_us, home_blurb): lower case and underscores. The client's own words, with any facts they give (when they started, where they are), go in --about, because the drafts never invent facts and leave [gaps] for the owner instead."
483
+ },
484
+ {
485
+ "id": "ai.reply.purpose",
486
+ "sets": "reply.purpose",
487
+ "ask": "What are these replies answering?",
488
+ "style": "cards",
489
+ "answers": [
490
+ {
491
+ "label": "Quote and price requests",
492
+ "value": "quote",
493
+ "means": "\"How much for Dec 12, 15 pax?\": the reply gives the price and asks only for what's missing."
494
+ },
495
+ {
496
+ "label": "Availability questions",
497
+ "value": "availability",
498
+ "means": "\"Available pa po ba sa Sabado?\": the reply says yes or offers other dates."
499
+ },
500
+ {
501
+ "label": "Booking or appointment requests",
502
+ "value": "booking",
503
+ "means": "\"May slot po ba bukas?\": the reply lists the free times and asks which one."
504
+ }
505
+ ],
506
+ "other": {
507
+ "kind": "text",
508
+ "min": 3,
509
+ "max": 200,
510
+ "description": "What the messages are, in a few words."
511
+ },
512
+ "must_ask": true,
513
+ "when": {
514
+ "id": "ai.route",
515
+ "is": [
516
+ "reply"
517
+ ]
518
+ },
519
+ "note": "Name the guide after it (venue-quote, appointment-slots): lower case and dashes. Say that prices and dates always come from the app's own details (workbench-availability's quote() where it's a booking), never from the AI."
520
+ },
521
+ {
522
+ "id": "ai.reply.facts",
523
+ "sets": "reply.facts",
524
+ "ask": "Here are the details a reply can use. Keep, change or add?",
525
+ "style": "table",
526
+ "must_ask": true,
527
+ "when": {
528
+ "id": "ai.route",
529
+ "is": [
530
+ "reply"
531
+ ]
532
+ },
533
+ "note": "Propose one row per fact from the client's words and what the app already holds: its name, what it is, where the app gets it, and whether it is money (x-money with the currency) or a date. Only these can appear in a reply; a fact the app doesn't have can't be quoted. Then ask which details a reply may ask the customer for, most important first (ask_order); intake's missing fields are asked from that list only."
534
+ },
535
+ {
536
+ "id": "ai.reply.language",
537
+ "sets": "reply.language",
538
+ "ask": "Which language should replies use?",
539
+ "style": "cards",
540
+ "answers": [
541
+ {
542
+ "label": "Match the message",
543
+ "value": "match",
544
+ "means": "English gets English, Taglish gets Taglish, Tagalog gets Tagalog. When it can't tell, it uses your fallback and asks you to check."
545
+ },
546
+ {
547
+ "label": "Always Taglish",
548
+ "value": "taglish"
549
+ },
550
+ {
551
+ "label": "Always English",
552
+ "value": "en"
553
+ },
554
+ {
555
+ "label": "Always Tagalog",
556
+ "value": "tl"
557
+ }
558
+ ],
559
+ "when": {
560
+ "id": "ai.route",
561
+ "is": [
562
+ "reply"
563
+ ]
564
+ },
565
+ "note": "This is the default. Where the app has accounts (a venue each), make it each account's own setting in its portal, defaulting to this, and pass it to ai.reply as language; with match, pass the account's fallback as fallbackLanguage."
566
+ },
567
+ {
568
+ "id": "ai.reply.voice",
569
+ "sets": "reply.voice",
570
+ "ask": "Which tone?",
571
+ "style": "cards",
572
+ "answers": [
573
+ {
574
+ "label": "Conversational",
575
+ "value": "conversational",
576
+ "means": "Warm and natural, like a helpful person at the front desk."
577
+ },
578
+ {
579
+ "label": "Casual",
580
+ "value": "casual",
581
+ "means": "Relaxed and friendly, like chatting with a regular."
582
+ },
583
+ {
584
+ "label": "Plain",
585
+ "value": "plain",
586
+ "means": "Short and clear, nothing extra."
587
+ },
588
+ {
589
+ "label": "Formal",
590
+ "value": "formal",
591
+ "means": "Polite and proper, for a business that keeps a formal tone."
592
+ }
593
+ ],
594
+ "when": {
595
+ "id": "ai.route",
596
+ "is": [
597
+ "reply"
598
+ ]
599
+ }
600
+ },
601
+ {
602
+ "id": "ai.reply.length",
603
+ "sets": "reply.max_words",
604
+ "ask": "How long can a reply be, at most?",
605
+ "style": "cards",
606
+ "answers": [
607
+ {
608
+ "label": "About 90 words",
609
+ "value": 90,
610
+ "means": "Room for a price or two and one question."
611
+ },
612
+ {
613
+ "label": "About 50 words",
614
+ "value": 50,
615
+ "means": "A quick chat reply."
616
+ },
617
+ {
618
+ "label": "About 150 words",
619
+ "value": 150,
620
+ "means": "Room for several packages or options."
621
+ }
622
+ ],
623
+ "other": {
624
+ "kind": "whole",
625
+ "min": 20,
626
+ "max": 300,
627
+ "description": "Words, from 20 to 300."
628
+ },
629
+ "when": {
630
+ "id": "ai.route",
631
+ "is": [
632
+ "reply"
633
+ ]
634
+ },
635
+ "note": "A reply up to a quarter over passes quietly; one over twice as long is sent back to be written again, then flagged for a person to check."
636
+ },
637
+ {
638
+ "id": "ai.reply.never",
639
+ "sets": "reply.never",
640
+ "ask": "Is there anything a reply must never say?",
641
+ "style": "cards",
642
+ "answers": [
643
+ {
644
+ "label": "The usual three",
645
+ "value": "usual",
646
+ "means": "Never promise a discount, confirm a booking, or ask for payment details. Code checks these as well as telling the AI."
647
+ },
648
+ {
649
+ "label": "The usual three, and more",
650
+ "value": "usual_and_more",
651
+ "means": "Those three plus things of your own (type them in Other)."
652
+ },
653
+ {
654
+ "label": "Nothing",
655
+ "value": "none",
656
+ "means": "No rules beyond Workbench's own content rules."
657
+ }
658
+ ],
659
+ "other": {
660
+ "kind": "list",
661
+ "min": 1,
662
+ "max": 10,
663
+ "description": "Each thing a reply must never do, in a few words (give medical advice, mention the pool)."
664
+ },
665
+ "must_ask": true,
666
+ "when": {
667
+ "id": "ai.route",
668
+ "is": [
669
+ "reply"
670
+ ]
671
+ },
672
+ "note": "Write each as a never line in the guide. Lines about payment details, discounts and confirming bookings are also checked by code; others go into the prompt only, so say so if the client's line matters a lot."
456
673
  }
457
674
  ],
458
675
  "not_asked": [
@@ -475,6 +692,9 @@ export const capabilities = {
475
692
  "command": "npx workbench-ai drafts text <name> --about \"<what it is for, with the client's facts>\""
476
693
  }
477
694
  },
695
+ "styles": {
696
+ "prefix": "wb-reply-"
697
+ },
478
698
  "delivery": {
479
699
  "choice": "built_in",
480
700
  "intro": "Writing, summarising and answering questions with Claude, with a monthly spending cap."
@@ -627,6 +847,271 @@ export const capabilities = {
627
847
  "intro": "Counting page views and the things visitors do on the site."
628
848
  }
629
849
  },
850
+ "availability": {
851
+ "$schema": "./schema.json",
852
+ "format": 1,
853
+ "component": "availability",
854
+ "title": "Availability and pricing",
855
+ "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.",
856
+ "status": "built",
857
+ "surfaces": {
858
+ "package": [
859
+ "quote",
860
+ "createAvailability",
861
+ "availabilityMigration",
862
+ "calendarAction",
863
+ "checkSettings",
864
+ "stayClaims",
865
+ "staysClash",
866
+ "todayIn",
867
+ "AvailabilityError"
868
+ ]
869
+ },
870
+ "rules": [
871
+ {
872
+ "id": "availability.quote-is-fact",
873
+ "kind": "must",
874
+ "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.",
875
+ "enforced_by": "review",
876
+ "probe": "availability.quote"
877
+ },
878
+ {
879
+ "id": "availability.book-through-block",
880
+ "kind": "must",
881
+ "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.",
882
+ "enforced_by": "database",
883
+ "probe": "availability.store"
884
+ },
885
+ {
886
+ "id": "availability.calendar-days",
887
+ "kind": "must",
888
+ "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.",
889
+ "enforced_by": "types",
890
+ "probe": "availability.quote"
891
+ },
892
+ {
893
+ "id": "availability.no-names-in-notes",
894
+ "kind": "must_not",
895
+ "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.",
896
+ "enforced_by": "review",
897
+ "probe": "availability.store"
898
+ },
899
+ {
900
+ "id": "availability.public-gets-the-quote",
901
+ "kind": "must_not",
902
+ "text": "Let a public page or the anonymous key read availability_blocks: public pages get the finished quote from the server.",
903
+ "enforced_by": "database",
904
+ "probe": "availability.store"
905
+ },
906
+ {
907
+ "id": "availability.daily-cleanup",
908
+ "kind": "must",
909
+ "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.",
910
+ "enforced_by": "review",
911
+ "probe": "availability.store"
912
+ },
913
+ {
914
+ "id": "availability.calendar-as-shipped",
915
+ "kind": "must",
916
+ "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.",
917
+ "enforced_by": "review",
918
+ "probe": "availability.calendar"
919
+ }
920
+ ],
921
+ "examples": [
922
+ {
923
+ "id": "availability.example.quote",
924
+ "title": "Answer \"is 12 Dec free, and how much?\"",
925
+ "lang": "ts",
926
+ "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)', ...]",
927
+ "probe": "availability.quote"
928
+ },
929
+ {
930
+ "id": "availability.example.calendar",
931
+ "title": "The owner's tap-to-block calendar",
932
+ "lang": "ts",
933
+ "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 })",
934
+ "probe": "availability.calendar"
935
+ }
936
+ ],
937
+ "probes": [
938
+ {
939
+ "id": "availability.quote",
940
+ "half": "runtime",
941
+ "expect": "pass",
942
+ "smoke": true,
943
+ "cost_hint_usd": 0,
944
+ "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"
945
+ },
946
+ {
947
+ "id": "availability.store",
948
+ "half": "runtime",
949
+ "expect": "pass",
950
+ "cost_hint_usd": 0,
951
+ "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"
952
+ },
953
+ {
954
+ "id": "availability.calendar",
955
+ "half": "runtime",
956
+ "expect": "pass",
957
+ "cost_hint_usd": 0,
958
+ "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"
959
+ }
960
+ ],
961
+ "offer": {
962
+ "pattern": "settings",
963
+ "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.",
964
+ "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.",
965
+ "answers_file": "workbench/availability.json",
966
+ "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.",
967
+ "questions": [
968
+ {
969
+ "id": "availability.what",
970
+ "sets": "availability.what",
971
+ "ask": "What do people book?",
972
+ "style": "table",
973
+ "other": {
974
+ "kind": "text",
975
+ "min": 3,
976
+ "max": 200,
977
+ "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\"."
978
+ },
979
+ "must_ask": true,
980
+ "note": "Only the client knows. Their answer names the resources (one per thing booked separately) and suggests the stay types to propose next."
981
+ },
982
+ {
983
+ "id": "availability.stays",
984
+ "sets": "availability.stay_types",
985
+ "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?",
986
+ "style": "table",
987
+ "other": {
988
+ "kind": "list",
989
+ "min": 1,
990
+ "max": 10,
991
+ "description": "Each way it's booked, with its start and end time and the others it can't share a date with."
992
+ },
993
+ "must_ask": true,
994
+ "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."
995
+ },
996
+ {
997
+ "id": "availability.events",
998
+ "sets": "availability.event_types",
999
+ "ask": "Should prices depend on the kind of event? Here is the usual list.",
1000
+ "style": "cards",
1001
+ "answers": [
1002
+ {
1003
+ "label": "Yes, use this list",
1004
+ "value": "list",
1005
+ "means": "Birthday, debut, wedding, baptism, reunion, team building, other: a rule can add a fee for some of them."
1006
+ },
1007
+ {
1008
+ "label": "No, same price for all",
1009
+ "value": "none",
1010
+ "means": "No event types; the quote never asks for one."
1011
+ },
1012
+ {
1013
+ "label": "Yes, with my own list",
1014
+ "value": "own",
1015
+ "means": "Type the kinds of event the business prices differently."
1016
+ }
1017
+ ],
1018
+ "other": {
1019
+ "kind": "list",
1020
+ "min": 1,
1021
+ "max": 20,
1022
+ "description": "The kinds of event, each a short name."
1023
+ },
1024
+ "note": "Recommend the usual list for venues and resorts; \"none\" for most other businesses. Write ids in lower case with underscores (team_building)."
1025
+ },
1026
+ {
1027
+ "id": "availability.holidays",
1028
+ "sets": "availability.holidays",
1029
+ "ask": "Should holidays be priced like weekends?",
1030
+ "style": "cards",
1031
+ "answers": [
1032
+ {
1033
+ "label": "Yes, like a weekend",
1034
+ "value": "weekend",
1035
+ "means": "A regular holiday or special non-working day on a weekday costs the weekend price."
1036
+ },
1037
+ {
1038
+ "label": "No, by the day of the week",
1039
+ "value": "weekday",
1040
+ "means": "Holidays cost what their day of the week costs."
1041
+ },
1042
+ {
1043
+ "label": "Set holiday prices separately",
1044
+ "value": "separate",
1045
+ "means": "Holidays get their own price rules, entered on the owner's pages."
1046
+ }
1047
+ ],
1048
+ "other": {
1049
+ "kind": "text",
1050
+ "min": 3,
1051
+ "max": 200,
1052
+ "description": "How holidays should be priced, in the client's words."
1053
+ },
1054
+ "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."
1055
+ },
1056
+ {
1057
+ "id": "availability.holds",
1058
+ "sets": "availability.hold_days",
1059
+ "ask": "How long is a date held while waiting for a deposit?",
1060
+ "style": "cards",
1061
+ "answers": [
1062
+ {
1063
+ "label": "3 days",
1064
+ "value": 3,
1065
+ "means": "The date shows as held (H) for 3 days, then is free again unless marked booked."
1066
+ },
1067
+ {
1068
+ "label": "1 day",
1069
+ "value": 1
1070
+ },
1071
+ {
1072
+ "label": "7 days",
1073
+ "value": 7
1074
+ },
1075
+ {
1076
+ "label": "No holds",
1077
+ "value": 0,
1078
+ "means": "A date is free until it is booked; nothing is held."
1079
+ }
1080
+ ],
1081
+ "other": {
1082
+ "kind": "whole",
1083
+ "min": 0,
1084
+ "max": 60,
1085
+ "description": "Days, 0 to 60."
1086
+ }
1087
+ }
1088
+ ],
1089
+ "not_asked": [
1090
+ {
1091
+ "sets": "availability.prices",
1092
+ "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."
1093
+ },
1094
+ {
1095
+ "sets": "availability.alternatives",
1096
+ "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."
1097
+ },
1098
+ {
1099
+ "sets": "availability.currency",
1100
+ "why": "It is the country's own, from workbench/country.json."
1101
+ }
1102
+ ],
1103
+ "needs": [
1104
+ "country"
1105
+ ]
1106
+ },
1107
+ "styles": {
1108
+ "prefix": "wb-cal-"
1109
+ },
1110
+ "delivery": {
1111
+ "choice": "optional",
1112
+ "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."
1113
+ }
1114
+ },
630
1115
  "config": {
631
1116
  "$schema": "./schema.json",
632
1117
  "format": 1,
@@ -781,7 +1266,11 @@ export const capabilities = {
781
1266
  "currencySymbol",
782
1267
  "currencySymbolFrom",
783
1268
  "WORLDWIDE",
784
- "isWorldwide"
1269
+ "isWorldwide",
1270
+ "HOLIDAYS",
1271
+ "holidaysFor",
1272
+ "holidayOn",
1273
+ "checkHolidays"
785
1274
  ]
786
1275
  },
787
1276
  "rules": [
@@ -806,6 +1295,13 @@ export const capabilities = {
806
1295
  "enforced_by": "types",
807
1296
  "probe": "country.packs"
808
1297
  },
1298
+ {
1299
+ "id": "country.holidays-from-proclamation",
1300
+ "kind": "must",
1301
+ "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.",
1302
+ "enforced_by": "review",
1303
+ "probe": "country.holidays"
1304
+ },
809
1305
  {
810
1306
  "id": "country.spelling-from-pack",
811
1307
  "kind": "must",
@@ -838,6 +1334,14 @@ export const capabilities = {
838
1334
  "expect": "pass",
839
1335
  "cost_hint_usd": 0,
840
1336
  "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"
1337
+ },
1338
+ {
1339
+ "id": "country.holidays",
1340
+ "half": "runtime",
1341
+ "expect": "pass",
1342
+ "smoke": true,
1343
+ "cost_hint_usd": 0,
1344
+ "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
1345
  }
842
1346
  ],
843
1347
  "offer": {
@@ -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";
@@ -26,6 +26,7 @@ export const COMPONENTS = [
26
26
  "ui",
27
27
  "forms",
28
28
  "sign-in",
29
+ "availability",
29
30
  ];
30
31
  const id = z.string().regex(/^[a-z0-9][a-z0-9.-]*$/, "lower case, digits, dots and hyphens");
31
32
  const code = z.string().regex(/^[a-z][a-z0-9_]*$/, "a snake_case error code");