@mccaskie-labs/workbench-config 0.1.27 → 0.1.29

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @mccaskie-labs/workbench-config
2
2
 
3
+ ## 0.1.29
4
+
5
+ ### Patch Changes
6
+
7
+ - Offer patterns (docs/capabilities-manifest.md §9.5): `offer.pattern` (settings, drafts, feature_or_content, project, switch_on), `question.when`, `offer.drafts`, `offer.needs` and a top-level `no_offer`. Every manifest needs an offer or a no_offer reason, except components in `OFFER_PENDING`. gateway, config and email-sending state why a client has nothing to answer. Generated skills gain per-pattern steps, branches and a Drafts section.
8
+
9
+ ## 0.1.28
10
+
11
+ ### Patch Changes
12
+
13
+ - Client questions: manifests gain an optional `offer` section (docs/capabilities-manifest.md §9), checked for unique question ids, two to four answers on a cards question, no written "Other", and each package option set once. `renderSkill` and `npx workbench-config skills [--check] [dir]` write or compare the project skills `.claude/skills/workbench-<component>/SKILL.md` generated from them. No manifest has an offer yet.
14
+
3
15
  ## 0.1.27
4
16
 
5
17
  ### Patch Changes
@@ -59,5 +59,6 @@
59
59
  { "id": "config.env-required", "half": "runtime", "expect": "pass", "cost_hint_usd": 0 },
60
60
  { "id": "config.brand-file", "half": "runtime", "expect": "pass", "cost_hint_usd": 0, "note": "The app's brand.json passes defineBrand and a bad one is refused with every bad field named" },
61
61
  { "id": "config.dev-vars-check", "half": "runtime", "expect": "pass", "cost_hint_usd": 0, "note": "Manual: run npx workbench-config check in the project" }
62
- ]
62
+ ],
63
+ "no_offer": "Plumbing: WORKBENCH_ENV and typed variables. The template sets them; there is nothing for a client to choose."
63
64
  }
@@ -34,5 +34,6 @@
34
34
  { "id": "email-sending.status", "half": "drill", "expect": "pass", "cost_hint_usd": 0 },
35
35
  { "id": "email-sending.sender-add", "half": "drill", "expect": "pass", "cost_hint_usd": 0, "note": "Then a runtime send from the new address (email.send.transactional)" },
36
36
  { "id": "email-sending.out-of-scope", "half": "drill", "expect": "refused_as_expected", "refusal": "out_of_scope", "cost_hint_usd": 0, "note": "Another project's namespace" }
37
- ]
37
+ ],
38
+ "no_offer": "Plumbing: sender domain onboarding is done through Control by the owner. The client's sender address is asked by the email component."
38
39
  }
@@ -79,5 +79,6 @@
79
79
  { "id": "gateway.revoked-key", "half": "runtime", "expect": "refused_as_expected", "refusal": "unauthorised", "cost_hint_usd": 0, "note": "Stops within 60 seconds of revocation" },
80
80
  { "id": "gateway.budget-enforced", "half": "runtime", "expect": "refused_as_expected", "refusal": "budget_exceeded", "cost_hint_usd": 0, "note": "Uses the development key's US$0 enforce budget, so it is refused before any spend (docs/phase-4-plan.md §3 Q10)" },
81
81
  { "id": "gateway.no-vendor-keys.scan", "half": "runtime", "expect": "pass", "cost_hint_usd": 0, "note": "Manual: grep of the repo and cf_worker_secret_list show only WORKBENCH_GATEWAY_* secrets" }
82
- ]
82
+ ],
83
+ "no_offer": "Plumbing: keys, scopes, headers and budgets shared by every Gateway endpoint. Budgets are set through Control, not by the client's answers."
83
84
  }
@@ -401,6 +401,286 @@
401
401
  ],
402
402
  "additionalProperties": false
403
403
  }
404
+ },
405
+ "offer": {
406
+ "type": "object",
407
+ "properties": {
408
+ "pattern": {
409
+ "type": "string",
410
+ "enum": [
411
+ "settings",
412
+ "drafts",
413
+ "feature_or_content",
414
+ "project",
415
+ "switch_on"
416
+ ]
417
+ },
418
+ "when": {
419
+ "type": "string",
420
+ "minLength": 1
421
+ },
422
+ "say": {
423
+ "type": "string",
424
+ "minLength": 1
425
+ },
426
+ "answers_file": {
427
+ "type": "string",
428
+ "pattern": "^workbench\\/[a-z0-9<>/._-]+\\.json$"
429
+ },
430
+ "per": {
431
+ "type": "string",
432
+ "minLength": 1
433
+ },
434
+ "generator": {
435
+ "type": "string",
436
+ "minLength": 1
437
+ },
438
+ "questions": {
439
+ "minItems": 1,
440
+ "type": "array",
441
+ "items": {
442
+ "type": "object",
443
+ "properties": {
444
+ "id": {
445
+ "type": "string",
446
+ "pattern": "^[a-z0-9][a-z0-9.-]*$"
447
+ },
448
+ "sets": {
449
+ "anyOf": [
450
+ {
451
+ "type": "string",
452
+ "minLength": 1
453
+ },
454
+ {
455
+ "minItems": 1,
456
+ "type": "array",
457
+ "items": {
458
+ "type": "string",
459
+ "minLength": 1
460
+ }
461
+ }
462
+ ]
463
+ },
464
+ "ask": {
465
+ "type": "string",
466
+ "minLength": 1
467
+ },
468
+ "style": {
469
+ "type": "string",
470
+ "enum": [
471
+ "cards",
472
+ "table"
473
+ ]
474
+ },
475
+ "answers": {
476
+ "minItems": 2,
477
+ "maxItems": 4,
478
+ "type": "array",
479
+ "items": {
480
+ "type": "object",
481
+ "properties": {
482
+ "label": {
483
+ "type": "string",
484
+ "minLength": 1,
485
+ "maxLength": 40
486
+ },
487
+ "value": {
488
+ "type": [
489
+ "string",
490
+ "number",
491
+ "boolean",
492
+ "null"
493
+ ]
494
+ },
495
+ "means": {
496
+ "type": "string",
497
+ "minLength": 1
498
+ }
499
+ },
500
+ "required": [
501
+ "label",
502
+ "value"
503
+ ],
504
+ "additionalProperties": false
505
+ }
506
+ },
507
+ "other": {
508
+ "type": "object",
509
+ "properties": {
510
+ "kind": {
511
+ "type": "string",
512
+ "enum": [
513
+ "text",
514
+ "whole",
515
+ "money",
516
+ "date",
517
+ "list"
518
+ ]
519
+ },
520
+ "min": {
521
+ "type": [
522
+ "number",
523
+ "string"
524
+ ]
525
+ },
526
+ "max": {
527
+ "type": [
528
+ "number",
529
+ "string"
530
+ ]
531
+ },
532
+ "description": {
533
+ "type": "string",
534
+ "minLength": 1
535
+ }
536
+ },
537
+ "required": [
538
+ "kind"
539
+ ],
540
+ "additionalProperties": false
541
+ },
542
+ "for": {
543
+ "minItems": 1,
544
+ "type": "array",
545
+ "items": {
546
+ "type": "string",
547
+ "minLength": 1
548
+ }
549
+ },
550
+ "must_ask": {
551
+ "type": "boolean"
552
+ },
553
+ "when": {
554
+ "type": "object",
555
+ "properties": {
556
+ "id": {
557
+ "type": "string",
558
+ "pattern": "^[a-z0-9][a-z0-9.-]*$"
559
+ },
560
+ "is": {
561
+ "minItems": 1,
562
+ "type": "array",
563
+ "items": {
564
+ "type": [
565
+ "string",
566
+ "number",
567
+ "boolean",
568
+ "null"
569
+ ]
570
+ }
571
+ }
572
+ },
573
+ "required": [
574
+ "id",
575
+ "is"
576
+ ],
577
+ "additionalProperties": false
578
+ },
579
+ "note": {
580
+ "type": "string",
581
+ "minLength": 1
582
+ }
583
+ },
584
+ "required": [
585
+ "id",
586
+ "sets",
587
+ "ask",
588
+ "style"
589
+ ],
590
+ "additionalProperties": false
591
+ }
592
+ },
593
+ "not_asked": {
594
+ "minItems": 1,
595
+ "type": "array",
596
+ "items": {
597
+ "type": "object",
598
+ "properties": {
599
+ "sets": {
600
+ "type": "string",
601
+ "minLength": 1
602
+ },
603
+ "why": {
604
+ "type": "string",
605
+ "minLength": 1
606
+ }
607
+ },
608
+ "required": [
609
+ "sets",
610
+ "why"
611
+ ],
612
+ "additionalProperties": false
613
+ }
614
+ },
615
+ "drafts": {
616
+ "type": "object",
617
+ "properties": {
618
+ "of": {
619
+ "type": "string",
620
+ "minLength": 1
621
+ },
622
+ "surface": {
623
+ "type": "string",
624
+ "enum": [
625
+ "ai",
626
+ "images",
627
+ "image-search"
628
+ ]
629
+ },
630
+ "alias": {
631
+ "type": "string",
632
+ "pattern": "^[a-z][a-z0-9-]*$"
633
+ },
634
+ "count": {
635
+ "type": "integer",
636
+ "minimum": 2,
637
+ "maximum": 4
638
+ },
639
+ "max_rounds": {
640
+ "type": "integer",
641
+ "minimum": 1,
642
+ "maximum": 5
643
+ },
644
+ "job": {
645
+ "type": "string",
646
+ "pattern": "^[a-z0-9._-]{1,64}$"
647
+ },
648
+ "command": {
649
+ "type": "string",
650
+ "minLength": 1
651
+ }
652
+ },
653
+ "required": [
654
+ "of",
655
+ "surface",
656
+ "count",
657
+ "max_rounds",
658
+ "job",
659
+ "command"
660
+ ],
661
+ "additionalProperties": false
662
+ },
663
+ "needs": {
664
+ "minItems": 1,
665
+ "type": "array",
666
+ "items": {
667
+ "type": "string",
668
+ "pattern": "^[a-z][a-z0-9-]*$"
669
+ }
670
+ }
671
+ },
672
+ "required": [
673
+ "pattern",
674
+ "when",
675
+ "say",
676
+ "answers_file",
677
+ "questions"
678
+ ],
679
+ "additionalProperties": false
680
+ },
681
+ "no_offer": {
682
+ "type": "string",
683
+ "minLength": 1
404
684
  }
405
685
  },
406
686
  "required": [
@@ -1,4 +1,4 @@
1
- export declare const MANIFEST_VERSION: "c83a7c612319";
1
+ export declare const MANIFEST_VERSION: "4538a59cfe87";
2
2
  export declare const capabilities: {
3
3
  readonly ai: {
4
4
  readonly $schema: "./schema.json";
@@ -355,6 +355,7 @@ export declare const capabilities: {
355
355
  readonly cost_hint_usd: 0;
356
356
  readonly note: "Manual: run npx workbench-config check in the project";
357
357
  }];
358
+ readonly no_offer: "Plumbing: WORKBENCH_ENV and typed variables. The template sets them; there is nothing for a client to choose.";
358
359
  };
359
360
  readonly country: {
360
361
  readonly $schema: "./schema.json";
@@ -1083,6 +1084,7 @@ export declare const capabilities: {
1083
1084
  readonly cost_hint_usd: 0;
1084
1085
  readonly note: "Another project's namespace";
1085
1086
  }];
1087
+ readonly no_offer: "Plumbing: sender domain onboarding is done through Control by the owner. The client's sender address is asked by the email component.";
1086
1088
  };
1087
1089
  readonly forms: {
1088
1090
  readonly $schema: "./schema.json";
@@ -1380,6 +1382,7 @@ export declare const capabilities: {
1380
1382
  readonly cost_hint_usd: 0;
1381
1383
  readonly note: "Manual: grep of the repo and cf_worker_secret_list show only WORKBENCH_GATEWAY_* secrets";
1382
1384
  }];
1385
+ readonly no_offer: "Plumbing: keys, scopes, headers and budgets shared by every Gateway endpoint. Budgets are set through Control, not by the client's answers.";
1383
1386
  };
1384
1387
  readonly "image-search": {
1385
1388
  readonly $schema: "./schema.json";
@@ -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 = "c83a7c612319";
3
+ export const MANIFEST_VERSION = "4538a59cfe87";
4
4
  export const capabilities = {
5
5
  "ai": {
6
6
  "$schema": "./schema.json",
@@ -521,7 +521,8 @@ export const capabilities = {
521
521
  "cost_hint_usd": 0,
522
522
  "note": "Manual: run npx workbench-config check in the project"
523
523
  }
524
- ]
524
+ ],
525
+ "no_offer": "Plumbing: WORKBENCH_ENV and typed variables. The template sets them; there is nothing for a client to choose."
525
526
  },
526
527
  "country": {
527
528
  "$schema": "./schema.json",
@@ -1473,7 +1474,8 @@ export const capabilities = {
1473
1474
  "cost_hint_usd": 0,
1474
1475
  "note": "Another project's namespace"
1475
1476
  }
1476
- ]
1477
+ ],
1478
+ "no_offer": "Plumbing: sender domain onboarding is done through Control by the owner. The client's sender address is asked by the email component."
1477
1479
  },
1478
1480
  "forms": {
1479
1481
  "$schema": "./schema.json",
@@ -1872,7 +1874,8 @@ export const capabilities = {
1872
1874
  "cost_hint_usd": 0,
1873
1875
  "note": "Manual: grep of the repo and cf_worker_secret_list show only WORKBENCH_GATEWAY_* secrets"
1874
1876
  }
1875
- ]
1877
+ ],
1878
+ "no_offer": "Plumbing: keys, scopes, headers and budgets shared by every Gateway endpoint. Budgets are set through Control, not by the client's answers."
1876
1879
  },
1877
1880
  "image-search": {
1878
1881
  "$schema": "./schema.json",
@@ -87,6 +87,73 @@ export declare const allowedSchemas: {
87
87
  }>>;
88
88
  }, z.core.$strict>;
89
89
  };
90
+ /** How Claude works with the client (docs/capabilities-manifest.md §9.5). */
91
+ export declare const OFFER_PATTERNS: readonly ["settings", "drafts", "feature_or_content", "project", "switch_on"];
92
+ declare const offer: z.ZodObject<{
93
+ pattern: z.ZodEnum<{
94
+ drafts: "drafts";
95
+ feature_or_content: "feature_or_content";
96
+ project: "project";
97
+ settings: "settings";
98
+ switch_on: "switch_on";
99
+ }>;
100
+ when: z.ZodString;
101
+ say: z.ZodString;
102
+ answers_file: z.ZodString;
103
+ per: z.ZodOptional<z.ZodString>;
104
+ generator: z.ZodOptional<z.ZodString>;
105
+ questions: z.ZodArray<z.ZodObject<{
106
+ id: z.ZodString;
107
+ sets: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
108
+ ask: z.ZodString;
109
+ style: z.ZodEnum<{
110
+ cards: "cards";
111
+ table: "table";
112
+ }>;
113
+ answers: z.ZodOptional<z.ZodArray<z.ZodObject<{
114
+ label: z.ZodString;
115
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>;
116
+ means: z.ZodOptional<z.ZodString>;
117
+ }, z.core.$strict>>>;
118
+ other: z.ZodOptional<z.ZodObject<{
119
+ kind: z.ZodEnum<{
120
+ date: "date";
121
+ list: "list";
122
+ money: "money";
123
+ text: "text";
124
+ whole: "whole";
125
+ }>;
126
+ min: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>;
127
+ max: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>;
128
+ description: z.ZodOptional<z.ZodString>;
129
+ }, z.core.$strict>>;
130
+ for: z.ZodOptional<z.ZodArray<z.ZodString>>;
131
+ must_ask: z.ZodOptional<z.ZodBoolean>;
132
+ when: z.ZodOptional<z.ZodObject<{
133
+ id: z.ZodString;
134
+ is: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>;
135
+ }, z.core.$strict>>;
136
+ note: z.ZodOptional<z.ZodString>;
137
+ }, z.core.$strict>>;
138
+ not_asked: z.ZodOptional<z.ZodArray<z.ZodObject<{
139
+ sets: z.ZodString;
140
+ why: z.ZodString;
141
+ }, z.core.$strict>>>;
142
+ drafts: z.ZodOptional<z.ZodObject<{
143
+ of: z.ZodString;
144
+ surface: z.ZodEnum<{
145
+ ai: "ai";
146
+ "image-search": "image-search";
147
+ images: "images";
148
+ }>;
149
+ alias: z.ZodOptional<z.ZodString>;
150
+ count: z.ZodNumber;
151
+ max_rounds: z.ZodNumber;
152
+ job: z.ZodString;
153
+ command: z.ZodString;
154
+ }, z.core.$strict>>;
155
+ needs: z.ZodOptional<z.ZodArray<z.ZodString>>;
156
+ }, z.core.$strict>;
90
157
  /** One file in `capabilities/`. Parsing checks shape only; cross-references are checked by `checkManifests`. */
91
158
  export declare const capabilitySchema: z.ZodObject<{
92
159
  $schema: z.ZodLiteral<"./schema.json">;
@@ -221,8 +288,81 @@ export declare const capabilitySchema: z.ZodObject<{
221
288
  two_step: z.ZodOptional<z.ZodBoolean>;
222
289
  note: z.ZodOptional<z.ZodString>;
223
290
  }, z.core.$strict>>;
291
+ offer: z.ZodOptional<z.ZodObject<{
292
+ pattern: z.ZodEnum<{
293
+ drafts: "drafts";
294
+ feature_or_content: "feature_or_content";
295
+ project: "project";
296
+ settings: "settings";
297
+ switch_on: "switch_on";
298
+ }>;
299
+ when: z.ZodString;
300
+ say: z.ZodString;
301
+ answers_file: z.ZodString;
302
+ per: z.ZodOptional<z.ZodString>;
303
+ generator: z.ZodOptional<z.ZodString>;
304
+ questions: z.ZodArray<z.ZodObject<{
305
+ id: z.ZodString;
306
+ sets: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
307
+ ask: z.ZodString;
308
+ style: z.ZodEnum<{
309
+ cards: "cards";
310
+ table: "table";
311
+ }>;
312
+ answers: z.ZodOptional<z.ZodArray<z.ZodObject<{
313
+ label: z.ZodString;
314
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>;
315
+ means: z.ZodOptional<z.ZodString>;
316
+ }, z.core.$strict>>>;
317
+ other: z.ZodOptional<z.ZodObject<{
318
+ kind: z.ZodEnum<{
319
+ date: "date";
320
+ list: "list";
321
+ money: "money";
322
+ text: "text";
323
+ whole: "whole";
324
+ }>;
325
+ min: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>;
326
+ max: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>;
327
+ description: z.ZodOptional<z.ZodString>;
328
+ }, z.core.$strict>>;
329
+ for: z.ZodOptional<z.ZodArray<z.ZodString>>;
330
+ must_ask: z.ZodOptional<z.ZodBoolean>;
331
+ when: z.ZodOptional<z.ZodObject<{
332
+ id: z.ZodString;
333
+ is: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>;
334
+ }, z.core.$strict>>;
335
+ note: z.ZodOptional<z.ZodString>;
336
+ }, z.core.$strict>>;
337
+ not_asked: z.ZodOptional<z.ZodArray<z.ZodObject<{
338
+ sets: z.ZodString;
339
+ why: z.ZodString;
340
+ }, z.core.$strict>>>;
341
+ drafts: z.ZodOptional<z.ZodObject<{
342
+ of: z.ZodString;
343
+ surface: z.ZodEnum<{
344
+ ai: "ai";
345
+ "image-search": "image-search";
346
+ images: "images";
347
+ }>;
348
+ alias: z.ZodOptional<z.ZodString>;
349
+ count: z.ZodNumber;
350
+ max_rounds: z.ZodNumber;
351
+ job: z.ZodString;
352
+ command: z.ZodString;
353
+ }, z.core.$strict>>;
354
+ needs: z.ZodOptional<z.ZodArray<z.ZodString>>;
355
+ }, z.core.$strict>>;
356
+ no_offer: z.ZodOptional<z.ZodString>;
224
357
  }, z.core.$strict>;
225
358
  export type Capability = z.infer<typeof capabilitySchema>;
359
+ export type Offer = z.infer<typeof offer>;
360
+ /**
361
+ * Components whose client questions are still being written (Robert,
362
+ * 11 November 2026: every shared tool ships its questions). The list only
363
+ * shrinks; a new component can't join it.
364
+ */
365
+ export declare const OFFER_PENDING: readonly ["ai", "analytics", "country", "database", "email", "email-inbound", "forms", "image-search", "images", "pdf", "strings", "ui"];
226
366
  export interface ManifestProblem {
227
367
  file: string;
228
368
  check: "schema" | "proof" | "ids" | "surfaces" | "errors" | "generated";
@@ -236,3 +376,4 @@ export declare function checkManifests(files: Record<string, unknown>): {
236
376
  manifests: Capability[];
237
377
  problems: ManifestProblem[];
238
378
  };
379
+ export {};
@@ -136,6 +136,60 @@ export const allowedSchemas = {
136
136
  environments: z.array(envName).min(1),
137
137
  }),
138
138
  };
139
+ // Client questions (docs/capabilities-manifest.md §9): what Claude Code asks
140
+ // the client when it builds with this component. A project skill is
141
+ // generated from it (`workbench-config skills`).
142
+ const answerValue = z.union([z.string(), z.number(), z.boolean(), z.null()]);
143
+ const otherRule = z.strictObject({
144
+ kind: z.enum(["text", "whole", "money", "date", "list"]),
145
+ min: z.union([z.number(), z.string()]).optional(),
146
+ max: z.union([z.number(), z.string()]).optional(),
147
+ description: z.string().min(1).optional(),
148
+ });
149
+ const question = z.strictObject({
150
+ id,
151
+ sets: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]),
152
+ ask: z.string().min(1),
153
+ /** `cards`: two to four answers, recommended first. `table`: a table of proposed values to accept or correct. */
154
+ style: z.enum(["cards", "table"]),
155
+ answers: z
156
+ .array(z.strictObject({ label: z.string().min(1).max(40), value: answerValue, means: z.string().min(1).optional() }))
157
+ .min(2)
158
+ .max(4)
159
+ .optional(),
160
+ /** Absent: "Other" isn't allowed. Present: what a typed answer must satisfy. */
161
+ other: otherRule.optional(),
162
+ for: z.array(z.string().min(1)).min(1).optional(),
163
+ must_ask: z.boolean().optional(),
164
+ /** Ask only when an earlier question's answer is one of `is` (the branch, e.g. feature or one-off). */
165
+ when: z.strictObject({ id, is: z.array(answerValue).min(1) }).optional(),
166
+ note: z.string().min(1).optional(),
167
+ });
168
+ /** How Claude works with the client (docs/capabilities-manifest.md §9.5). */
169
+ export const OFFER_PATTERNS = ["settings", "drafts", "feature_or_content", "project", "switch_on"];
170
+ const drafts = z.strictObject({
171
+ /** What is drafted, in plain words: "invoice layouts", "a hero image". */
172
+ of: z.string().min(1),
173
+ surface: z.enum(["ai", "images", "image-search"]),
174
+ alias: z.string().regex(/^[a-z][a-z0-9-]*$/).optional(),
175
+ count: z.number().int().min(2).max(4),
176
+ max_rounds: z.number().int().min(1).max(5),
177
+ job: z.string().regex(/^[a-z0-9._-]{1,64}$/),
178
+ command: z.string().min(1),
179
+ });
180
+ const offer = z.strictObject({
181
+ pattern: z.enum(OFFER_PATTERNS),
182
+ when: z.string().min(1),
183
+ say: z.string().min(1),
184
+ answers_file: z.string().regex(/^workbench\/[a-z0-9<>/._-]+\.json$/, "a JSON file under workbench/"),
185
+ per: z.string().min(1).optional(),
186
+ generator: z.string().min(1).optional(),
187
+ questions: z.array(question).min(1),
188
+ not_asked: z.array(z.strictObject({ sets: z.string().min(1), why: z.string().min(1) })).min(1).optional(),
189
+ drafts: drafts.optional(),
190
+ /** Project setup answers that must exist first: their components' answers files. */
191
+ needs: z.array(z.string().regex(/^[a-z][a-z0-9-]*$/)).min(1).optional(),
192
+ });
139
193
  const baseShape = {
140
194
  $schema: z.literal("./schema.json"),
141
195
  format: z.literal(1),
@@ -158,6 +212,9 @@ const baseShape = {
158
212
  rules: z.array(rule).min(1),
159
213
  examples: z.array(example).min(1).optional(),
160
214
  probes: z.array(probe).min(1),
215
+ offer: offer.optional(),
216
+ /** Why a client has nothing to answer for this component (plumbing). A manifest has `offer` or `no_offer`. */
217
+ no_offer: z.string().min(1).optional(),
161
218
  };
162
219
  /** One file in `capabilities/`. Parsing checks shape only; cross-references are checked by `checkManifests`. */
163
220
  export const capabilitySchema = z.strictObject(baseShape).superRefine((m, ctx) => {
@@ -175,6 +232,51 @@ export const capabilitySchema = z.strictObject(baseShape).superRefine((m, ctx) =
175
232
  }
176
233
  }
177
234
  });
235
+ /**
236
+ * Components whose client questions are still being written (Robert,
237
+ * 11 November 2026: every shared tool ships its questions). The list only
238
+ * shrinks; a new component can't join it.
239
+ */
240
+ export const OFFER_PENDING = ["ai", "analytics", "country", "database", "email", "email-inbound", "forms", "image-search", "images", "pdf", "strings", "ui"];
241
+ function checkOffer(file, component, o, seen) {
242
+ const problems = [];
243
+ const named = new Map();
244
+ const name = (option, by) => {
245
+ const prior = named.get(option);
246
+ if (prior)
247
+ problems.push({ file, check: "ids", message: `offer: ${option} is set by ${prior} and ${by}` });
248
+ else
249
+ named.set(option, by);
250
+ };
251
+ const asked = new Set();
252
+ if (o.pattern === "drafts" && !o.drafts)
253
+ problems.push({ file, check: "schema", message: "offer: the drafts pattern needs drafts" });
254
+ for (const q of o.questions) {
255
+ if (q.when && !asked.has(q.when.id))
256
+ problems.push({ file, check: "ids", message: `question ${q.id} depends on ${q.when.id}, which isn't an earlier question` });
257
+ asked.add(q.id);
258
+ seen("question", q.id);
259
+ if (!q.id.startsWith(`${component}.`))
260
+ problems.push({ file, check: "ids", message: `question ${q.id} must start with ${component}.` });
261
+ if (q.style === "cards" && !q.answers)
262
+ problems.push({ file, check: "schema", message: `question ${q.id} is cards but has no answers` });
263
+ if (q.style === "table" && q.answers)
264
+ problems.push({ file, check: "schema", message: `question ${q.id} is a table, so it lists no answers` });
265
+ const labels = new Set();
266
+ for (const a of q.answers ?? []) {
267
+ if (labels.has(a.label))
268
+ problems.push({ file, check: "ids", message: `question ${q.id} has the answer ${a.label} twice` });
269
+ labels.add(a.label);
270
+ if (/^other$/i.test(a.label))
271
+ problems.push({ file, check: "schema", message: `question ${q.id}: "Other" is added by Claude Code; use \`other\` instead` });
272
+ }
273
+ for (const option of typeof q.sets === "string" ? [q.sets] : q.sets)
274
+ name(option, `question ${q.id}`);
275
+ }
276
+ for (const n of o.not_asked ?? [])
277
+ name(n.sets, "not_asked");
278
+ return problems;
279
+ }
178
280
  /**
179
281
  * The checks that need only the manifests themselves (§5: schema, proof, ids).
180
282
  * `files` maps a file name (e.g. `ai.json`) to its parsed JSON.
@@ -244,6 +346,16 @@ export function checkManifests(files) {
244
346
  if (a.gate === "two_step_when" && !a.when)
245
347
  problems.push({ file, check: "schema", message: `action ${a.tool} is two_step_when but has no when` });
246
348
  }
349
+ if (m.offer)
350
+ problems.push(...checkOffer(file, m.component, m.offer, seen));
351
+ if (m.offer && m.no_offer)
352
+ problems.push({ file, check: "schema", message: "has both offer and no_offer" });
353
+ const pending = OFFER_PENDING.includes(m.component);
354
+ if (!m.offer && !m.no_offer && !pending) {
355
+ 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" });
356
+ }
357
+ if ((m.offer || m.no_offer) && pending)
358
+ problems.push({ file, check: "schema", message: `remove ${m.component} from OFFER_PENDING` });
247
359
  }
248
360
  return { manifests, problems };
249
361
  }
package/dist/cli.js CHANGED
@@ -1,15 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  // `npx workbench-config check [dir]`: compares the variable names in
3
3
  // <dir>/.dev.vars with <dir>/.dev.vars.example. Prints names only.
4
- import { readFileSync, existsSync } from "node:fs";
5
- import { join } from "node:path";
4
+ // `npx workbench-config skills [--check] [dir]`: writes (or, with --check,
5
+ // compares) the project skills generated from the manifests' offer sections
6
+ // (docs/capabilities-manifest.md §9).
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
6
10
  import { compareDevVars } from "./dev-vars.js";
7
- function main(argv) {
8
- const [command, dir = "."] = argv;
9
- if (command !== "check") {
10
- console.error("Usage: workbench-config check [dir]");
11
- return 2;
12
- }
11
+ import { planSkills } from "./skills-plan.js";
12
+ const USAGE = "Usage: workbench-config check [dir]\n workbench-config skills [--check] [dir]";
13
+ function checkDevVars(dir) {
13
14
  const examplePath = join(dir, ".dev.vars.example");
14
15
  const devVarsPath = join(dir, ".dev.vars");
15
16
  if (!existsSync(examplePath)) {
@@ -31,4 +32,50 @@ function main(argv) {
31
32
  console.error(`In .dev.vars but not in .dev.vars.example: ${result.extra.join(", ")}`);
32
33
  return 1;
33
34
  }
35
+ function skills(args) {
36
+ const check = args.includes("--check");
37
+ const dir = args.find((a) => a !== "--check") ?? ".";
38
+ const pkg = join(dirname(fileURLToPath(import.meta.url)), "..");
39
+ const version = JSON.parse(readFileSync(join(pkg, "package.json"), "utf8")).version;
40
+ const { files, stale, problems } = planSkills(join(pkg, "capabilities"), dir);
41
+ if (problems.length) {
42
+ for (const p of problems)
43
+ console.error(p);
44
+ return 1;
45
+ }
46
+ if (check) {
47
+ const wrong = Object.entries(files).filter(([path, text]) => !existsSync(join(dir, path)) || readFileSync(join(dir, path), "utf8") !== text).map(([path]) => path);
48
+ for (const path of wrong)
49
+ console.error(`${path} is missing or differs from workbench-config ${version}.`);
50
+ for (const path of stale)
51
+ console.error(`${path} is no longer generated by workbench-config ${version}.`);
52
+ if (wrong.length || stale.length) {
53
+ console.error("Run `npx workbench-config skills` and commit the result.");
54
+ return 1;
55
+ }
56
+ console.log(`Skills match workbench-config ${version} (${Object.keys(files).length}).`);
57
+ return 0;
58
+ }
59
+ for (const [path, text] of Object.entries(files)) {
60
+ mkdirSync(dirname(join(dir, path)), { recursive: true });
61
+ writeFileSync(join(dir, path), text);
62
+ console.log(`wrote ${path}`);
63
+ }
64
+ for (const path of stale) {
65
+ rmSync(join(dir, path));
66
+ if (readdirSync(dirname(join(dir, path))).length === 0)
67
+ rmSync(dirname(join(dir, path)), { recursive: true });
68
+ console.log(`removed ${path}`);
69
+ }
70
+ return 0;
71
+ }
72
+ function main(argv) {
73
+ const [command, ...rest] = argv;
74
+ if (command === "check")
75
+ return checkDevVars(rest[0] ?? ".");
76
+ if (command === "skills")
77
+ return skills(rest);
78
+ console.error(USAGE);
79
+ return 2;
80
+ }
34
81
  process.exitCode = main(process.argv.slice(2));
package/dist/index.d.ts CHANGED
@@ -2,7 +2,8 @@ export { WORKBENCH_ENVS, DEFAULT_GATEWAY_URL, workbenchEnv, type WorkbenchEnv }
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, } from "./capability-schema.ts";
5
+ export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, type Capability, type Component, type ManifestProblem, type Offer, OFFER_PATTERNS, OFFER_PENDING, } from "./capability-schema.ts";
6
+ export { renderSkill, renderSkills, skillName, SKILLS_DIR } from "./skills.ts";
6
7
  export { capabilities, MANIFEST_VERSION, type Alias, type GatewayScope } from "./capabilities.gen.ts";
7
8
  export { compareDevVars, parseDevVarNames } from "./dev-vars.ts";
8
9
  export { brandSchema, defineBrand, BRAND_FILE, resolveBrand, BRAND_DEFAULTS, type Brand, type ResolvedBrand } from "./brand.ts";
package/dist/index.js CHANGED
@@ -2,7 +2,8 @@ export { WORKBENCH_ENVS, DEFAULT_GATEWAY_URL, 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, } from "./capability-schema.js";
5
+ export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, OFFER_PATTERNS, OFFER_PENDING, } from "./capability-schema.js";
6
+ export { renderSkill, renderSkills, skillName, SKILLS_DIR } from "./skills.js";
6
7
  export { capabilities, MANIFEST_VERSION } from "./capabilities.gen.js";
7
8
  export { compareDevVars, parseDevVarNames } from "./dev-vars.js";
8
9
  export { brandSchema, defineBrand, BRAND_FILE, resolveBrand, BRAND_DEFAULTS } from "./brand.js";
@@ -0,0 +1,7 @@
1
+ export declare const GENERATED_MARK = "Generated by `npx workbench-config skills`";
2
+ /** Skill files the manifests in `manifestDir` produce, and the generated ones in `projectDir` they don't. */
3
+ export declare function planSkills(manifestDir: string, projectDir: string): {
4
+ files: Record<string, string>;
5
+ stale: string[];
6
+ problems: string[];
7
+ };
@@ -0,0 +1,26 @@
1
+ // Node only: reads a manifests directory and a project, for
2
+ // `workbench-config skills` (cli.ts). Kept out of index.ts, which runs in Workers.
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { checkManifests } from "./capability-schema.js";
6
+ import { renderSkills, SKILLS_DIR } from "./skills.js";
7
+ export const GENERATED_MARK = "Generated by `npx workbench-config skills`";
8
+ /** Skill files the manifests in `manifestDir` produce, and the generated ones in `projectDir` they don't. */
9
+ export function planSkills(manifestDir, projectDir) {
10
+ const json = {};
11
+ for (const f of readdirSync(manifestDir).filter((f) => f.endsWith(".json") && f !== "schema.json")) {
12
+ json[f] = JSON.parse(readFileSync(join(manifestDir, f), "utf8"));
13
+ }
14
+ const { manifests, problems } = checkManifests(json);
15
+ const files = renderSkills(manifests);
16
+ const stale = [];
17
+ const skillsDir = join(projectDir, SKILLS_DIR);
18
+ if (existsSync(skillsDir)) {
19
+ for (const d of readdirSync(skillsDir)) {
20
+ const path = `${SKILLS_DIR}/${d}/SKILL.md`;
21
+ if (d.startsWith("workbench-") && existsSync(join(projectDir, path)) && !(path in files) && readFileSync(join(projectDir, path), "utf8").includes(GENERATED_MARK))
22
+ stale.push(path);
23
+ }
24
+ }
25
+ return { files, stale, problems: problems.map((p) => `${p.file}: [${p.check}] ${p.message}`) };
26
+ }
@@ -0,0 +1,7 @@
1
+ import type { Capability } from "./capability-schema.ts";
2
+ export declare const SKILLS_DIR = ".claude/skills";
3
+ export declare const skillName: (component: string) => string;
4
+ /** The SKILL.md for one manifest, or undefined when it has no offer. */
5
+ export declare function renderSkill(m: Capability): string | undefined;
6
+ /** Every skill the manifests produce, keyed by its path in a project. */
7
+ export declare function renderSkills(manifests: readonly Capability[]): Record<string, string>;
package/dist/skills.js ADDED
@@ -0,0 +1,125 @@
1
+ // Project skills generated from the manifests' `offer` sections
2
+ // (docs/capabilities-manifest.md §9). `workbench-config skills` writes one
3
+ // `.claude/skills/workbench-<component>/SKILL.md` per offer into a project,
4
+ // and `--check` fails when a file differs, so the questions Claude Code asks
5
+ // a client never drift from what the package accepts.
6
+ export const SKILLS_DIR = ".claude/skills";
7
+ export const skillName = (component) => `workbench-${component}`;
8
+ const show = (v) => (v === null ? "none" : `\`${JSON.stringify(v)}\``);
9
+ const cell = (s) => s.replaceAll("|", "\\|").replaceAll("\n", " ");
10
+ const sets = (q) => (typeof q.sets === "string" ? [q.sets] : q.sets).map((s) => `\`${s}\``).join(", ");
11
+ function otherLine(q) {
12
+ if (!q.other)
13
+ return "Other: not allowed. Only the answers above are accepted.";
14
+ const { kind, min, max, description } = q.other;
15
+ const range = min !== undefined && max !== undefined ? ` from ${min} to ${max}` : min !== undefined ? ` of at least ${min}` : max !== undefined ? ` of at most ${max}` : "";
16
+ const what = { text: "text", whole: "a whole number", money: "an amount", date: "a date (YYYY-MM-DD)", list: "a list, one item per line" }[kind];
17
+ const length = kind === "text" && range ? ` (length in characters)` : "";
18
+ return `Other: allowed. The client must type ${what}${range}${length}.${description ? ` ${description}` : ""} Refuse anything else and ask again.`;
19
+ }
20
+ function questionBlock(q, n) {
21
+ const lines = [`### ${n}. ${q.ask}`, "", `Id \`${q.id}\`. Sets ${sets(q)}.`];
22
+ if (q.for)
23
+ lines.push(`Only for: ${q.for.map((f) => `\`${f}\``).join(", ")}.`);
24
+ if (q.when)
25
+ lines.push(`Ask only when \`${q.when.id}\` is ${q.when.is.map(show).join(" or ")}.`);
26
+ if (q.must_ask)
27
+ lines.push("**Must ask:** there is no safe default, so always ask it, even when the client accepts every recommendation.");
28
+ lines.push("");
29
+ if (q.style === "table") {
30
+ lines.push("Ask as a table: propose a value for each row, show the table, then ask one question with the answers \"Accept all (Recommended)\", \"Change some\" and \"Add or remove\".");
31
+ }
32
+ else {
33
+ lines.push("| Answer | Value | Means |", "| --- | --- | --- |");
34
+ (q.answers ?? []).forEach((a, i) => lines.push(`| ${cell(a.label)}${i === 0 ? " (Recommended)" : ""} | ${show(a.value)} | ${cell(a.means ?? "")} |`));
35
+ }
36
+ lines.push("", otherLine(q));
37
+ if (q.note)
38
+ lines.push("", q.note);
39
+ return lines.join("\n");
40
+ }
41
+ const PATTERN_STEPS = {
42
+ settings: [
43
+ "Propose everything at once: a table of every setting with its recommended value. Then ask one question: \"Accept all (Recommended)\", \"Change some\" or \"Add or remove\".",
44
+ "The client edits the list in plain words (\"delete this\", \"add a dropdown with these values, required\"). Update the answers file and show the table again until they accept it.",
45
+ ],
46
+ drafts: [
47
+ "Ask what the thing is first, then offer examples to pick from (see Drafts below). Show them, let the client pick one or ask for another round, and keep only the one picked.",
48
+ ],
49
+ feature_or_content: [
50
+ "Ask the routing question first: is this a feature visitors use on the site, or something made once for the site? Then ask only the questions for that route.",
51
+ "A feature for visitors spends money every time someone uses it: always ask who may use it and its monthly cap.",
52
+ ],
53
+ project: [
54
+ "These are project-wide answers, asked once. If the answers file already exists, use it and don't ask again; offer to change it only if the client asks.",
55
+ ],
56
+ switch_on: [
57
+ "The template already has this. Confirm the client wants it, ask the few questions below, and name any step the owner takes in Workbench Control.",
58
+ ],
59
+ };
60
+ function draftsBlock(d) {
61
+ return [
62
+ "## Drafts",
63
+ "",
64
+ `Offer ${d.count} examples of ${d.of} to pick from, at most ${d.max_rounds} round${d.max_rounds === 1 ? "" : "s"}. Make them with \`${d.command}\`: it calls the Workbench Gateway (${d.surface}${d.alias ? `, \`${d.alias}\`` : ""}, job \`${d.job}\`) with the project's development key from \`.dev.vars\`, spends from the development budget, and prints what each round cost. Tell the client the cost it prints.`,
65
+ "",
66
+ "Never write drafts yourself and never call a vendor directly. If the command says there is no development key, say so and offer the standard version instead; never ask for a key in chat. Record the picked draft's request id, model and cost in the answers file.",
67
+ ].join("\n");
68
+ }
69
+ /** The SKILL.md for one manifest, or undefined when it has no offer. */
70
+ export function renderSkill(m) {
71
+ const o = m.offer;
72
+ if (!o)
73
+ return undefined;
74
+ const description = `${o.when} Offers Workbench ${m.title} and asks the client for its settings.`.replaceAll("\n", " ");
75
+ const out = [
76
+ "---",
77
+ `name: ${skillName(m.component)}`,
78
+ `description: ${JSON.stringify(description)}`,
79
+ "---",
80
+ "",
81
+ `<!-- Generated by \`npx workbench-config skills\` from @mccaskie-labs/workbench-config, capabilities/${m.component}.json. Don't edit: \`npx workbench-config skills --check\` fails when this file differs. -->`,
82
+ "",
83
+ `# ${m.title}`,
84
+ "",
85
+ m.summary,
86
+ "",
87
+ "## When this applies",
88
+ "",
89
+ o.when,
90
+ "",
91
+ "## What to say",
92
+ "",
93
+ `Say: "${o.say}" Don't ask whether to use Workbench or something of the client's own. If the client says they don't want it, stop and say what they give up.`,
94
+ "",
95
+ "## How to ask",
96
+ "",
97
+ ...(o.needs?.length ? [`- First check the project setup this needs: ${o.needs.map((n) => `\`workbench/${n}.json\``).join(", ")}. If one is missing, use that component's \`workbench-<component>\` skill first.`] : []),
98
+ ...PATTERN_STEPS[o.pattern].map((step) => `- ${step}`),
99
+ "- Ask with the AskUserQuestion tool, up to four questions per call. Give each question's answers in the order below; the first is recommended, so label it \"(Recommended)\". The tool adds \"Other\" itself.",
100
+ "- Where the client accepts every recommendation, still ask each \"Must ask\" question.",
101
+ "- An \"Other\" answer must meet the rule under its question. When it doesn't, say why and ask again. Never store a value outside it.",
102
+ "- Ask nothing that isn't listed here, and invent no setting. A need the questions don't cover goes back to the client as a plain question, and isn't written to the answers file.",
103
+ `- Write the answers to \`${o.answers_file}\`${o.per ? `, one entry per ${o.per}` : ""}: for each question its id, the value, and the client's own words when they typed "Other" or changed a recommendation.${o.generator ? ` Then run \`${o.generator}\`, which writes the code and refuses an out-of-range answer. Never edit what it writes; change the answers file and run it again.` : ""}`,
104
+ "",
105
+ "## Questions",
106
+ "",
107
+ o.questions.map((q, i) => questionBlock(q, i + 1)).join("\n\n"),
108
+ ];
109
+ if (o.drafts)
110
+ out.push("", draftsBlock(o.drafts));
111
+ if (o.not_asked?.length) {
112
+ out.push("", "## Not asked", "", ...o.not_asked.map((n) => `- \`${n.sets}\`: ${n.why}`));
113
+ }
114
+ return `${out.join("\n")}\n`;
115
+ }
116
+ /** Every skill the manifests produce, keyed by its path in a project. */
117
+ export function renderSkills(manifests) {
118
+ const files = {};
119
+ for (const m of manifests) {
120
+ const text = renderSkill(m);
121
+ if (text)
122
+ files[`${SKILLS_DIR}/${skillName(m.component)}/SKILL.md`] = text;
123
+ }
124
+ return files;
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mccaskie-labs/workbench-config",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "description": "Workbench app configuration: WORKBENCH_ENV, typed variables that fail by name, secrets that are never printed, and the capabilities manifest.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",