@mccaskie-labs/workbench-config 0.2.4 → 0.3.0

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 = "15786d683e1e";
3
+ export const MANIFEST_VERSION = "de365af27674";
4
4
  export const capabilities = {
5
5
  "ai": {
6
6
  "$schema": "./schema.json",
@@ -577,10 +577,54 @@ export const capabilities = {
577
577
  "note": "Create the site, drive page views, query them back (cf_analytics_query, later)"
578
578
  }
579
579
  ],
580
+ "offer": {
581
+ "pattern": "switch_on",
582
+ "when": "The client wants to know how many people visit, which pages they open, or to count an action such as a sign-up or an order.",
583
+ "say": "The project already has Workbench visitor counts: page views counted by Cloudflare without cookies, and the actions you choose counted in the app's own database. I'll switch on what you want.",
584
+ "answers_file": "workbench/analytics.json",
585
+ "answers_format": "`{ \"format\": 1, \"page_views\": true, \"events\": [{ \"name\": \"order.placed\", \"when\": \"a customer sends an order\", \"details\": [\"items\", \"total_band\"] }] }`. For page views, create the Web Analytics site with Workbench Control (`cf_web_analytics_site_create`), write its beacon token onto staging and production (`cf_worker_secret_put source=web_analytics`), and put `beacon(env)` on every page customers see. For each event, call `eventsFor(db).track(name, details)` where the action happens.",
586
+ "questions": [
587
+ {
588
+ "id": "analytics.page-views",
589
+ "sets": "page_views",
590
+ "ask": "Should the site count its page views?",
591
+ "style": "cards",
592
+ "answers": [
593
+ {
594
+ "label": "Yes",
595
+ "value": true,
596
+ "means": "How many people visit and which pages they open, counted by Cloudflare. No cookies, and no personal details kept."
597
+ },
598
+ {
599
+ "label": "No",
600
+ "value": false,
601
+ "means": "Nothing is counted on the site's pages."
602
+ }
603
+ ],
604
+ "note": "Only pages customers see; never the owner's or admins' pages."
605
+ },
606
+ {
607
+ "id": "analytics.actions",
608
+ "sets": "events",
609
+ "ask": "Which actions should be counted?",
610
+ "style": "table",
611
+ "note": "Propose the few actions that show whether the site is working for the business (a sign-up, an order, a booking, a form sent), each with a name (`thing.past_verb`, lower case: `order.placed`), when it happens in the client's words, and its details. Details are counts, choices and bands (`items`, `plan`, `total_band`), never an email address, a name, a phone number or free text someone typed. The client accepts, removes or adds; an empty list is fine."
612
+ }
613
+ ],
614
+ "not_asked": [
615
+ {
616
+ "sets": "consent",
617
+ "why": "The page-view counter sets no cookies and keeps no personal details, and events hold none either, so they add nothing that a cookie notice would need to cover."
618
+ },
619
+ {
620
+ "sets": "reports",
621
+ "why": "Page views are seen in Cloudflare's Web Analytics, and events are rows in the app's own `events` table; ask what the owner wants to see when they ask for a report."
622
+ }
623
+ ]
624
+ },
580
625
  "delivery": {
581
626
  "choice": "optional",
582
- "intro": "Counting page views and the things visitors do on the site.",
583
- "when": "The client wants to know how many people visit, which pages they open, or to count an action such as a sign-up or an order."
627
+ "intro": "Counting page views and the things visitors do on the site."
584
628
  }
585
629
  },
586
630
  "config": {
@@ -796,10 +840,167 @@ export const capabilities = {
796
840
  "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"
797
841
  }
798
842
  ],
843
+ "offer": {
844
+ "pattern": "project",
845
+ "when": "The app shows a date, a time, a number, a phone number or an amount of money to its readers, or the client says where their readers are.",
846
+ "say": "I'll write dates, times and money with Workbench country formats, the way your readers expect them. That needs to know where they are, once for the whole site.",
847
+ "answers_file": "workbench/country.json",
848
+ "answers_format": "`{ \"format\": 1, \"country\": \"AU\", \"time_zone\": null, \"currency\": null }`: the country code (or `ZZ`, the package's WORLDWIDE, for readers everywhere), and an IANA time zone or `{ \"code\", \"symbol\" }` currency only when they differ from the country's own (the Australian and US time questions both fill `time_zone`). Then set `READERS` in `src/lib/country.ts` to match: `audiencePack({ country, timeZone, currency })`, leaving out what is null. Every page, email, PDF and form reads `READERS`, so nothing else changes.",
849
+ "questions": [
850
+ {
851
+ "id": "country.where",
852
+ "sets": "audience.country",
853
+ "ask": "Where are most of the site's readers?",
854
+ "style": "cards",
855
+ "answers": [
856
+ {
857
+ "label": "Australia",
858
+ "value": "AU",
859
+ "means": "26 September 2026, A$1,400, Sydney time, Australian spelling."
860
+ },
861
+ {
862
+ "label": "The Philippines",
863
+ "value": "PH",
864
+ "means": "September 26, 2026, ₱1,400, Manila time."
865
+ },
866
+ {
867
+ "label": "The United Kingdom",
868
+ "value": "GB",
869
+ "means": "26 September 2026, £1,400, UK time, British spelling."
870
+ },
871
+ {
872
+ "label": "The United States",
873
+ "value": "US",
874
+ "means": "September 26, 2026, US$1,400, Eastern time, American spelling."
875
+ }
876
+ ],
877
+ "other": {
878
+ "kind": "text",
879
+ "min": 2,
880
+ "max": 60,
881
+ "description": "Another country, or readers all over the world."
882
+ },
883
+ "note": "Recommend Australia unless the client's words say otherwise. A country with no pack yet, or readers everywhere, gets the neutral one (`ZZ`: 26 September 2026, 24-hour clock, UTC, US$): say so, and ask for the currency."
884
+ },
885
+ {
886
+ "id": "country.au-time",
887
+ "sets": "audience.time_zone.au",
888
+ "ask": "Which Australian time should the site show?",
889
+ "style": "cards",
890
+ "answers": [
891
+ {
892
+ "label": "Sydney, Melbourne, Canberra",
893
+ "value": null,
894
+ "means": "The pack's own (Australia/Sydney), with daylight saving."
895
+ },
896
+ {
897
+ "label": "Brisbane",
898
+ "value": "Australia/Brisbane",
899
+ "means": "Queensland, no daylight saving."
900
+ },
901
+ {
902
+ "label": "Adelaide",
903
+ "value": "Australia/Adelaide",
904
+ "means": "South Australia, half an hour behind Sydney."
905
+ },
906
+ {
907
+ "label": "Perth",
908
+ "value": "Australia/Perth",
909
+ "means": "Western Australia."
910
+ }
911
+ ],
912
+ "other": {
913
+ "kind": "text",
914
+ "min": 5,
915
+ "max": 40,
916
+ "description": "Another Australian time zone, such as Australia/Darwin or Australia/Hobart."
917
+ },
918
+ "when": {
919
+ "id": "country.where",
920
+ "is": [
921
+ "AU"
922
+ ]
923
+ },
924
+ "note": "The time zone of the business, where bookings and opening hours happen. Hobart follows Sydney; Darwin is Australia/Darwin."
925
+ },
926
+ {
927
+ "id": "country.us-time",
928
+ "sets": "audience.time_zone.us",
929
+ "ask": "Which US time should the site show?",
930
+ "style": "cards",
931
+ "answers": [
932
+ {
933
+ "label": "Eastern",
934
+ "value": null,
935
+ "means": "The pack's own (America/New_York)."
936
+ },
937
+ {
938
+ "label": "Central",
939
+ "value": "America/Chicago"
940
+ },
941
+ {
942
+ "label": "Mountain",
943
+ "value": "America/Denver"
944
+ },
945
+ {
946
+ "label": "Pacific",
947
+ "value": "America/Los_Angeles"
948
+ }
949
+ ],
950
+ "other": {
951
+ "kind": "text",
952
+ "min": 5,
953
+ "max": 40,
954
+ "description": "Another US time zone, such as America/Phoenix or Pacific/Honolulu."
955
+ },
956
+ "when": {
957
+ "id": "country.where",
958
+ "is": [
959
+ "US"
960
+ ]
961
+ },
962
+ "note": "The time zone of the business."
963
+ },
964
+ {
965
+ "id": "country.currency",
966
+ "sets": "audience.currency",
967
+ "ask": "Which money are prices in?",
968
+ "style": "cards",
969
+ "answers": [
970
+ {
971
+ "label": "The country's own",
972
+ "value": null,
973
+ "means": "A$ in Australia, ₱ in the Philippines, £ in the UK, US$ in the US."
974
+ },
975
+ {
976
+ "label": "US dollars",
977
+ "value": "USD",
978
+ "means": "Shown as US$1,400."
979
+ }
980
+ ],
981
+ "other": {
982
+ "kind": "text",
983
+ "min": 3,
984
+ "max": 3,
985
+ "description": "Another currency's three-letter code, such as NZD or EUR."
986
+ },
987
+ "note": "Write a currency other than the country's as `{ \"code\", \"symbol\" }` (NZD is NZ$). Ask it only when the client sells something; otherwise record null."
988
+ }
989
+ ],
990
+ "not_asked": [
991
+ {
992
+ "sets": "audience.format",
993
+ "why": "Date order, the clock, number separators, spelling and phone number shapes all come from the country's pack, so they match what readers expect without asking."
994
+ },
995
+ {
996
+ "sets": "audience.owner_pages",
997
+ "why": "Pages only the owner sees use workbench-ui's own times and money, not the readers'."
998
+ }
999
+ ]
1000
+ },
799
1001
  "delivery": {
800
1002
  "choice": "optional",
801
- "intro": "Dates, times, numbers and money written the way readers in your country expect.",
802
- "when": "The app shows a date, a time, a number, a phone number or an amount of money to its readers, or the client says where their readers are."
1003
+ "intro": "Dates, times, numbers and money written the way readers in your country expect."
803
1004
  }
804
1005
  },
805
1006
  "database": {
@@ -1037,10 +1238,39 @@ export const capabilities = {
1037
1238
  "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"
1038
1239
  }
1039
1240
  ],
1241
+ "offer": {
1242
+ "pattern": "settings",
1243
+ "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.",
1244
+ "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.",
1245
+ "answers_file": "workbench/database.json",
1246
+ "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`.",
1247
+ "questions": [
1248
+ {
1249
+ "id": "database.tables",
1250
+ "sets": "tables",
1251
+ "ask": "What should the app store, and who can see and change it?",
1252
+ "style": "table",
1253
+ "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."
1254
+ }
1255
+ ],
1256
+ "not_asked": [
1257
+ {
1258
+ "sets": "keys",
1259
+ "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."
1260
+ },
1261
+ {
1262
+ "sets": "hosting",
1263
+ "why": "Each project has its own staging and production databases, made by Workbench Control in the region set for the install."
1264
+ },
1265
+ {
1266
+ "sets": "sign_in_settings",
1267
+ "why": "The database's sign-in settings follow the Workbench baseline (the workbench-sign-in skill)."
1268
+ }
1269
+ ]
1270
+ },
1040
1271
  "delivery": {
1041
1272
  "choice": "built_in",
1042
- "intro": "The app's own database, one for testing and one for the live site.",
1043
- "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."
1273
+ "intro": "The app's own database, one for testing and one for the live site."
1044
1274
  }
1045
1275
  },
1046
1276
  "email": {
@@ -1345,13 +1575,77 @@ export const capabilities = {
1345
1575
  "note": "GET brand.json's logo.url from outside with no sign-in answers 200 with an image content type"
1346
1576
  }
1347
1577
  ],
1578
+ "offer": {
1579
+ "pattern": "settings",
1580
+ "when": "The app needs to send an email: a confirmation, receipt, notification, invitation or sign-in code.",
1581
+ "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.",
1582
+ "answers_file": "workbench/email.json",
1583
+ "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.",
1584
+ "questions": [
1585
+ {
1586
+ "id": "email.emails",
1587
+ "sets": "emails",
1588
+ "ask": "Which emails should the app send?",
1589
+ "style": "table",
1590
+ "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."
1591
+ },
1592
+ {
1593
+ "id": "email.reply-to",
1594
+ "sets": "reply_to",
1595
+ "ask": "Where should a reply to these emails go?",
1596
+ "style": "cards",
1597
+ "answers": [
1598
+ {
1599
+ "label": "Nowhere",
1600
+ "value": null,
1601
+ "means": "The emails come from an address that isn't read. Say so in the small print, with where to get help instead."
1602
+ },
1603
+ {
1604
+ "label": "To the app",
1605
+ "value": "app",
1606
+ "means": "Replies go to an address the app reads itself (Workbench inbound email)."
1607
+ }
1608
+ ],
1609
+ "other": {
1610
+ "kind": "text",
1611
+ "min": 6,
1612
+ "max": 254,
1613
+ "description": "An email address someone at the business reads."
1614
+ },
1615
+ "must_ask": true,
1616
+ "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."
1617
+ }
1618
+ ],
1619
+ "not_asked": [
1620
+ {
1621
+ "sets": "sender",
1622
+ "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."
1623
+ },
1624
+ {
1625
+ "sets": "marketing",
1626
+ "why": "Only transactional email exists: newsletters and other bulk mail are refused until Workbench has a sender for them, so offer none."
1627
+ },
1628
+ {
1629
+ "sets": "look",
1630
+ "why": "Emails take the brand's name, colour and logo, and the project's `project.css` (see Restyling)."
1631
+ }
1632
+ ],
1633
+ "drafts": {
1634
+ "of": "wording for one email",
1635
+ "surface": "ai",
1636
+ "alias": "claude-default",
1637
+ "count": 3,
1638
+ "max_rounds": 3,
1639
+ "job": "ai.drafts",
1640
+ "command": "npx workbench-ai drafts text email_<id> --about \"<what the email is for, who gets it and its parts>\""
1641
+ }
1642
+ },
1348
1643
  "styles": {
1349
1644
  "prefix": "wb-email-"
1350
1645
  },
1351
1646
  "delivery": {
1352
1647
  "choice": "built_in",
1353
- "intro": "The emails the app sends, such as confirmations, receipts and sign-in codes.",
1354
- "when": "The app needs to send an email: a confirmation, receipt, notification, invitation or sign-in code."
1648
+ "intro": "The emails the app sends, such as confirmations, receipts and sign-in codes."
1355
1649
  }
1356
1650
  },
1357
1651
  "email-inbound": {
@@ -1481,10 +1775,54 @@ export const capabilities = {
1481
1775
  "note": "cf_email_routing_rule_delete returns a confirmation and changes nothing until Robert approves"
1482
1776
  }
1483
1777
  ],
1778
+ "offer": {
1779
+ "pattern": "switch_on",
1780
+ "when": "The client wants the app to receive email: replies to its messages, or an address such as hello@ that the app handles.",
1781
+ "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.",
1782
+ "answers_file": "workbench/email-inbound.json",
1783
+ "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).",
1784
+ "questions": [
1785
+ {
1786
+ "id": "email-inbound.addresses",
1787
+ "sets": "addresses",
1788
+ "ask": "Which addresses should the app receive, and what should it do with each message?",
1789
+ "style": "table",
1790
+ "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."
1791
+ },
1792
+ {
1793
+ "id": "email-inbound.attachments",
1794
+ "sets": "attachments",
1795
+ "ask": "What should happen to files attached to these emails?",
1796
+ "style": "cards",
1797
+ "answers": [
1798
+ {
1799
+ "label": "Leave them out",
1800
+ "value": "drop",
1801
+ "means": "The app keeps the words of each email, not its files. Nothing extra to store or check."
1802
+ },
1803
+ {
1804
+ "label": "Keep them",
1805
+ "value": "keep",
1806
+ "means": "The app stores each file for the admins. Files can be large or unsafe, so keep only the types the job needs."
1807
+ }
1808
+ ],
1809
+ "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`."
1810
+ }
1811
+ ],
1812
+ "not_asked": [
1813
+ {
1814
+ "sets": "domain",
1815
+ "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`)."
1816
+ },
1817
+ {
1818
+ "sets": "forwarding",
1819
+ "why": "Workbench never forwards mail outside the project or catches every address: each literal address goes to the app."
1820
+ }
1821
+ ]
1822
+ },
1484
1823
  "delivery": {
1485
1824
  "choice": "optional",
1486
- "intro": "Receiving email at the project's own addresses, for the app to act on.",
1487
- "when": "The client wants the app to receive email: replies to its messages, or an address such as hello@ that the app handles."
1825
+ "intro": "Receiving email at the project's own addresses, for the app to act on."
1488
1826
  }
1489
1827
  },
1490
1828
  "email-sending": {
@@ -3409,10 +3747,53 @@ export const capabilities = {
3409
3747
  "note": "The app's own checkStrings run passes in its tests, and npx workbench-strings check passes in CI"
3410
3748
  }
3411
3749
  ],
3750
+ "offer": {
3751
+ "pattern": "project",
3752
+ "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.",
3753
+ "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.",
3754
+ "answers_file": "workbench/strings.json",
3755
+ "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.",
3756
+ "questions": [
3757
+ {
3758
+ "id": "strings.languages",
3759
+ "sets": "languages",
3760
+ "ask": "Does the site need to be in any language besides English?",
3761
+ "style": "cards",
3762
+ "answers": [
3763
+ {
3764
+ "label": "English only",
3765
+ "value": "none",
3766
+ "means": "Every page and email is in English."
3767
+ },
3768
+ {
3769
+ "label": "Another language later",
3770
+ "value": "later",
3771
+ "means": "English now. The words are kept so a translation can be added, checked by a native speaker, without changing the code."
3772
+ }
3773
+ ],
3774
+ "other": {
3775
+ "kind": "text",
3776
+ "min": 2,
3777
+ "max": 120,
3778
+ "description": "The languages, such as Filipino or Spanish."
3779
+ },
3780
+ "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."
3781
+ }
3782
+ ],
3783
+ "not_asked": [
3784
+ {
3785
+ "sets": "base",
3786
+ "why": "The site is written first in English, in the readers' spelling from the country answers, so it isn't asked again."
3787
+ },
3788
+ {
3789
+ "sets": "wording",
3790
+ "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."
3791
+ }
3792
+ ]
3793
+ },
3412
3794
  "delivery": {
3413
3795
  "choice": "built_in",
3414
- "intro": "The words on the app's screens and in its emails, kept in one place for each language.",
3415
- "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."
3796
+ "intro": "The words on the app's screens and in its emails, kept in one place for each language."
3416
3797
  }
3417
3798
  },
3418
3799
  "ui": {
@@ -3505,13 +3886,38 @@ export const capabilities = {
3505
3886
  "note": "In the Worker itself: compares money (US$0.0042, US$12.50, and an en dash for nothing), duration (640 ms, 2.35 s, 3 min 5 s) and timeLeft against a fixed now (42 minutes left) with the expected text, and checks when and shortWhen write one fixed moment as its Manila day and hour with UTC beside it"
3506
3887
  }
3507
3888
  ],
3889
+ "offer": {
3890
+ "pattern": "switch_on",
3891
+ "when": "The client wants a page only the owner or the app's admins see: a dashboard, a list, a settings page or a status page.",
3892
+ "say": "I'll build this with the Workbench owner-page look, the same top bar, cards and pills as the other admin pages, so it's quick to build and consistent. Your customers never see it.",
3893
+ "answers_file": "workbench/ui.json",
3894
+ "answers_format": "`{ \"format\": 1, \"pages\": [{ \"path\": \"/admin/orders\", \"what\": \"every order this month, newest first\", \"who\": \"admins\" }] }`. Build each page with `shell`, `topBar`, `card` and `pill`. An `admins` page lives under `/admin/` and checks `requireUser` and `app_admins` (the workbench-sign-in skill); an `owner` page lives under `/owner/` and is behind Access in production.",
3895
+ "questions": [
3896
+ {
3897
+ "id": "ui.pages",
3898
+ "sets": "pages",
3899
+ "ask": "Which admin pages should the app have?",
3900
+ "style": "table",
3901
+ "note": "Propose the pages the request needs, each with its path, what it shows or lets them change in the client's words, and who sees it: `admins` (the business's people, who sign in to the app) or `owner` (whoever runs the app's Workbench, through Access). Recommend `admins` unless the page is about running the app itself, such as a status page. The client accepts, removes or adds pages."
3902
+ }
3903
+ ],
3904
+ "not_asked": [
3905
+ {
3906
+ "sets": "look",
3907
+ "why": "Every admin page has the same Workbench look, so the business's people learn it once. Restyle it in project.css (see Restyling) if the client wants their colours."
3908
+ },
3909
+ {
3910
+ "sets": "formats",
3911
+ "why": "Times, amounts and durations on these pages come from workbench-ui's `when`, `money` and `duration`. What customers see uses the readers' formats (the workbench-country skill)."
3912
+ }
3913
+ ]
3914
+ },
3508
3915
  "styles": {
3509
3916
  "prefix": "wb-ui-"
3510
3917
  },
3511
3918
  "delivery": {
3512
3919
  "choice": "optional",
3513
- "intro": "A ready-made look for the pages only you and your admins see.",
3514
- "when": "The client wants a page only the owner or the app's admins see: a dashboard, a list, a settings page or a status page."
3920
+ "intro": "A ready-made look for the pages only you and your admins see."
3515
3921
  }
3516
3922
  }
3517
3923
  };
@@ -391,17 +391,10 @@ export declare const capabilitySchema: z.ZodObject<{
391
391
  optional: "optional";
392
392
  }>;
393
393
  intro: z.ZodString;
394
- when: z.ZodOptional<z.ZodString>;
395
394
  }, z.core.$strict>>;
396
395
  }, z.core.$strict>;
397
396
  export type Capability = z.infer<typeof capabilitySchema>;
398
397
  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 ["analytics", "country", "database", "email", "email-inbound", "strings", "ui"];
405
398
  export interface ManifestProblem {
406
399
  file: string;
407
400
  check: "schema" | "proof" | "ids" | "surfaces" | "errors" | "generated";
@@ -203,8 +203,6 @@ const delivery = z.strictObject({
203
203
  choice: z.enum(["built_in", "optional"]),
204
204
  /** One plain sentence for the owner, shown when Claude introduces the tools. */
205
205
  intro: z.string().min(1).max(160),
206
- /** When the tool applies, in the client's words. Only for a component with no `offer`, whose `offer.when` says it. */
207
- when: z.string().min(1).optional(),
208
206
  });
209
207
  const baseShape = {
210
208
  $schema: z.literal("./schema.json"),
@@ -262,12 +260,6 @@ export const capabilitySchema = z.strictObject(baseShape).superRefine((m, ctx) =
262
260
  }
263
261
  }
264
262
  });
265
- /**
266
- * Components whose client questions are still being written (Robert,
267
- * 11 November 2026: every shared tool ships its questions). The list only
268
- * shrinks; a new component can't join it.
269
- */
270
- export const OFFER_PENDING = ["analytics", "country", "database", "email", "email-inbound", "strings", "ui"];
271
263
  function checkOffer(file, component, o, seen) {
272
264
  const problems = [];
273
265
  const named = new Map();
@@ -381,21 +373,14 @@ export function checkManifests(files) {
381
373
  problems.push(...checkOffer(file, m.component, m.offer, seen));
382
374
  if (m.offer && m.no_offer)
383
375
  problems.push({ file, check: "schema", message: "has both offer and no_offer" });
384
- const pending = OFFER_PENDING.includes(m.component);
385
- if (!m.offer && !m.no_offer && !pending) {
376
+ if (!m.offer && !m.no_offer) {
386
377
  problems.push({ file, check: "schema", message: "every component ships its client questions: add an offer, or no_offer with the reason a client has nothing to answer" });
387
378
  }
388
- if ((m.offer || m.no_offer) && pending)
389
- problems.push({ file, check: "schema", message: `remove ${m.component} from OFFER_PENDING` });
390
379
  // §9.6: every component a client's Claude Code works with says how it reaches the project.
391
380
  if (m.no_offer && m.delivery)
392
381
  problems.push({ file, check: "schema", message: "has both no_offer and delivery: plumbing gets no skill of its own" });
393
382
  if (!m.no_offer && !m.delivery)
394
383
  problems.push({ file, check: "schema", message: "add delivery: whether the tool is built in or optional, and one sentence that introduces it to the owner" });
395
- if (m.delivery && m.offer && m.delivery.when)
396
- problems.push({ file, check: "schema", message: "delivery.when repeats offer.when: remove it" });
397
- if (m.delivery && !m.offer && !m.delivery.when)
398
- problems.push({ file, check: "schema", message: "delivery needs when, because there is no offer to say when the tool applies" });
399
384
  }
400
385
  const byComponent = new Map(manifests.map((m) => [m.component, m]));
401
386
  for (const { file, m, own } of deferred) {
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { WORKBENCH_ENVS, workbenchEnv, type WorkbenchEnv } from "./env.ts";
2
2
  export { defineConfig, type Config } from "./define-config.ts";
3
3
  export { sharedSecret, workerSecret, redact, redactAll, type SecretsStoreSecretLike } from "./secrets.ts";
4
4
  export { WorkbenchConfigError, type ConfigErrorCode, type ConfigProblem } from "./errors.ts";
5
- export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, type Capability, type Component, type ManifestProblem, type Offer, OFFER_PATTERNS, OFFER_PENDING, } from "./capability-schema.ts";
5
+ export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, type Capability, type Component, type ManifestProblem, type Offer, OFFER_PATTERNS, } from "./capability-schema.ts";
6
6
  export { projectNotes, renderSkill, renderSkills, renderStart, skillName, SKILLS_DIR, START_SKILL, TOOLS_FILE } from "./skills.ts";
7
7
  export { toolsState } from "./tools.ts";
8
8
  export type { ToolChoices, ToolsState } from "./tools.ts";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ export { WORKBENCH_ENVS, workbenchEnv } from "./env.js";
2
2
  export { defineConfig } from "./define-config.js";
3
3
  export { sharedSecret, workerSecret, redact, redactAll } from "./secrets.js";
4
4
  export { WorkbenchConfigError } from "./errors.js";
5
- export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, OFFER_PATTERNS, OFFER_PENDING, } from "./capability-schema.js";
5
+ export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, OFFER_PATTERNS, } from "./capability-schema.js";
6
6
  export { projectNotes, renderSkill, renderSkills, renderStart, skillName, SKILLS_DIR, START_SKILL, TOOLS_FILE } from "./skills.js";
7
7
  export { toolsState } from "./tools.js";
8
8
  export { capabilities, MANIFEST_VERSION } from "./capabilities.gen.js";
package/dist/skills.d.ts CHANGED
@@ -6,7 +6,7 @@ export declare const TOOLS_FILE = "workbench/tools.json";
6
6
  export declare const START_SKILL = "workbench-start";
7
7
  /** The project's own notes on a tool, beside its generated skill: where this project wires it in. Never generated. */
8
8
  export declare const projectNotes: (component: string) => string;
9
- /** The SKILL.md for one manifest, or undefined when it has no `delivery` (plumbing). `guide` is its `capabilities/<component>.md`, when there is one. */
9
+ /** The SKILL.md for one manifest, or undefined for plumbing (no `delivery` or no `offer`). `guide` is its `capabilities/<component>.md`, when there is one. */
10
10
  export declare function renderSkill(m: Capability, guide?: string): string | undefined;
11
11
  /** The `workbench-start` skill: every tool introduced to the owner, and how their choices are recorded (§9.6). */
12
12
  export declare function renderStart(manifests: readonly Capability[]): string;