@stackwright-pro/mcp 0.2.0-alpha.7 → 0.2.0-alpha.71

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/dist/server.mjs CHANGED
@@ -3,15 +3,44 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
 
5
5
  // src/tools/data-explorer.ts
6
- import { z } from "zod";
6
+ import { z as z2 } from "zod";
7
7
  import { listEntities, generateFilter } from "@stackwright-pro/cli-data-explorer";
8
+
9
+ // src/coerce.ts
10
+ import { z } from "zod";
11
+ function jsonCoerce(schema) {
12
+ return z.preprocess((v) => {
13
+ if (typeof v === "string") {
14
+ try {
15
+ return JSON.parse(v);
16
+ } catch {
17
+ return v;
18
+ }
19
+ }
20
+ return v;
21
+ }, schema);
22
+ }
23
+ function boolCoerce(schema) {
24
+ return z.preprocess((v) => v === "true" ? true : v === "false" ? false : v, schema);
25
+ }
26
+ function numCoerce(schema) {
27
+ return z.preprocess((v) => {
28
+ if (typeof v === "string" && v.trim() !== "") {
29
+ const n = Number(v);
30
+ if (!Number.isNaN(n)) return n;
31
+ }
32
+ return v;
33
+ }, schema);
34
+ }
35
+
36
+ // src/tools/data-explorer.ts
8
37
  function registerDataExplorerTools(server2) {
9
38
  server2.tool(
10
39
  "stackwright_pro_list_entities",
11
40
  "List all available API entities from OpenAPI specs or generated Zod schemas. Use this to discover what entities are available before generating endpoint filters. Returns entity names, endpoints, and field counts. Part of the Pro Otter Raft for building API-integrated Stackwright applications.",
12
41
  {
13
- specPath: z.string().optional().describe("Path to OpenAPI spec file (YAML or JSON)"),
14
- projectRoot: z.string().optional().describe("Project root directory (auto-detected if omitted)")
42
+ specPath: z2.string().optional().describe("Path to OpenAPI spec file (YAML or JSON)"),
43
+ projectRoot: z2.string().optional().describe("Project root directory (auto-detected if omitted)")
15
44
  },
16
45
  async ({ specPath, projectRoot }) => {
17
46
  const result = listEntities({
@@ -59,9 +88,13 @@ function registerDataExplorerTools(server2) {
59
88
  "stackwright_pro_generate_filter",
60
89
  "Generate endpoint filter configuration from selected entities. Creates include/exclude patterns for stackwright.yml OpenAPI integration. Use this after stackwright_pro_list_entities to select which API endpoints the application needs. Only selected endpoints will generate client code, reducing bundle size and improving security.",
61
90
  {
62
- selectedEntities: z.array(z.string()).describe('Entity slugs to include (e.g., ["equipment", "supplies"])'),
63
- excludePatterns: z.array(z.string()).optional().describe('Glob patterns to exclude (e.g., ["/admin/**", "/reports/**"])'),
64
- projectRoot: z.string().optional().describe("Project root directory")
91
+ selectedEntities: jsonCoerce(z2.array(z2.string())).describe(
92
+ 'Entity slugs to include (e.g., ["equipment", "supplies"])'
93
+ ),
94
+ excludePatterns: jsonCoerce(z2.array(z2.string()).optional()).describe(
95
+ 'Glob patterns to exclude (e.g., ["/admin/**", "/reports/**"])'
96
+ ),
97
+ projectRoot: z2.string().optional().describe("Project root directory")
65
98
  },
66
99
  async ({ selectedEntities, excludePatterns, projectRoot }) => {
67
100
  const result = generateFilter({
@@ -117,7 +150,7 @@ function registerDataExplorerTools(server2) {
117
150
  }
118
151
 
119
152
  // src/tools/security.ts
120
- import { z as z2 } from "zod";
153
+ import { z as z3 } from "zod";
121
154
  import { createHash } from "crypto";
122
155
  import fs from "fs";
123
156
  import path from "path";
@@ -126,8 +159,8 @@ function registerSecurityTools(server2) {
126
159
  "stackwright_pro_validate_spec",
127
160
  "Validate an OpenAPI spec against the enterprise approved-specs configuration. Checks if the spec URL is on the allowlist and verifies SHA-256 hash integrity. Use this in enterprise environments where only pre-approved API specs are allowed. Fails build if spec is not approved or has been modified.",
128
161
  {
129
- specPath: z2.string().describe("URL or file path to the OpenAPI spec to validate"),
130
- configPath: z2.string().optional().describe("Path to stackwright.yml with prebuild.security config")
162
+ specPath: z3.string().describe("URL or file path to the OpenAPI spec to validate"),
163
+ configPath: z3.string().optional().describe("Path to stackwright.yml with prebuild.security config")
131
164
  },
132
165
  async ({ specPath, configPath }) => {
133
166
  let securityEnabled = false;
@@ -235,16 +268,15 @@ Status: Valid (${allowlist.length} specs on allowlist)`
235
268
  "stackwright_pro_add_approved_spec",
236
269
  "Add an OpenAPI spec to the approved-specs allowlist in stackwright.yml. Computes the SHA-256 hash of the spec and adds it to the security configuration. Use this when onboarding a new API in enterprise environments.",
237
270
  {
238
- name: z2.string().describe("Human-readable name for the spec"),
239
- url: z2.string().describe("URL or file path to the OpenAPI spec"),
240
- configPath: z2.string().optional().describe("Path to stackwright.yml")
271
+ name: z3.string().describe("Human-readable name for the spec"),
272
+ url: z3.string().describe("URL or file path to the OpenAPI spec"),
273
+ configPath: z3.string().optional().describe("Path to stackwright.yml")
241
274
  },
242
275
  async ({ name, url, configPath }) => {
243
276
  const configFile = configPath || path.join(process.cwd(), "stackwright.yml");
244
277
  let sha256 = "<computed-at-build>";
245
278
  try {
246
279
  if (url.startsWith("http://") || url.startsWith("https://")) {
247
- sha256 = "<computed-at-build>";
248
280
  } else if (fs.existsSync(url)) {
249
281
  const specContent = fs.readFileSync(url, "utf8");
250
282
  sha256 = createHash("sha256").update(specContent).digest("hex");
@@ -291,7 +323,7 @@ SHA-256 computed: ${sha256}`
291
323
  "stackwright_pro_list_approved_specs",
292
324
  "List all specs currently on the approved-specs allowlist. Shows spec names, URLs, and hash prefixes. Use this to audit what APIs are approved in the project.",
293
325
  {
294
- configPath: z2.string().optional().describe("Path to stackwright.yml")
326
+ configPath: z3.string().optional().describe("Path to stackwright.yml")
295
327
  },
296
328
  async ({ configPath }) => {
297
329
  const configFile = configPath || path.join(process.cwd(), "stackwright.yml");
@@ -360,16 +392,18 @@ SHA-256 computed: ${sha256}`
360
392
  }
361
393
 
362
394
  // src/tools/isr.ts
363
- import { z as z3 } from "zod";
395
+ import { z as z4 } from "zod";
364
396
  function registerIsrTools(server2) {
365
397
  server2.tool(
366
398
  "stackwright_pro_configure_isr",
367
399
  "Configure Incremental Static Regeneration (ISR) for an API-backed collection. ISR allows API data to be cached and refreshed on a schedule, providing real-time data with the performance of static generation. Use this after stackwright_pro_generate_filter to set revalidation intervals.",
368
400
  {
369
- collection: z3.string().describe('Collection name (e.g., "equipment", "supplies")'),
370
- revalidateSeconds: z3.number().optional().describe("Revalidation interval in seconds (default: 60)"),
371
- fallback: z3.enum(["blocking", "true", "false"]).optional().describe("Fallback behavior for new pages"),
372
- configPath: z3.string().optional().describe("Path to stackwright.yml")
401
+ collection: z4.string().describe('Collection name (e.g., "equipment", "supplies")'),
402
+ revalidateSeconds: numCoerce(z4.number().optional()).describe(
403
+ "Revalidation interval in seconds (default: 60)"
404
+ ),
405
+ fallback: z4.enum(["blocking", "true", "false"]).optional().describe("Fallback behavior for new pages"),
406
+ configPath: z4.string().optional().describe("Path to stackwright.yml")
373
407
  },
374
408
  async ({ collection, revalidateSeconds = 60, fallback = "blocking", configPath }) => {
375
409
  const revalidate = revalidateSeconds;
@@ -380,7 +414,7 @@ function registerIsrTools(server2) {
380
414
  isr:
381
415
  revalidate: ${revalidate}
382
416
  fallback: ${fallback}`;
383
- let description = "";
417
+ let description;
384
418
  if (revalidate < 60) {
385
419
  description = "\u26A1 Very fresh data (revalidate every " + revalidate + "s)";
386
420
  } else if (revalidate < 300) {
@@ -412,14 +446,16 @@ ${yamlSnippet}
412
446
  "stackwright_pro_configure_isr_batch",
413
447
  "Configure ISR for multiple collections at once. More efficient than calling stackwright_pro_configure_isr multiple times. Provide different revalidation intervals based on data freshness requirements.",
414
448
  {
415
- collections: z3.array(
416
- z3.object({
417
- name: z3.string().describe("Collection name"),
418
- revalidateSeconds: z3.number().optional().describe("Revalidation interval")
419
- })
449
+ collections: jsonCoerce(
450
+ z4.array(
451
+ z4.object({
452
+ name: z4.string().describe("Collection name"),
453
+ revalidateSeconds: numCoerce(z4.number().optional()).describe("Revalidation interval")
454
+ })
455
+ )
420
456
  ).describe("Array of collection configurations"),
421
- defaultFallback: z3.enum(["blocking", "true", "false"]).optional().describe("Default fallback behavior"),
422
- configPath: z3.string().optional().describe("Path to stackwright.yml")
457
+ defaultFallback: z4.enum(["blocking", "true", "false"]).optional().describe("Default fallback behavior"),
458
+ configPath: z4.string().optional().describe("Path to stackwright.yml")
423
459
  },
424
460
  async ({ collections, defaultFallback = "blocking", configPath }) => {
425
461
  const lines = [`\u2699\uFE0F Batch ISR Configuration:
@@ -447,17 +483,17 @@ Fallback: ${defaultFallback}`);
447
483
  }
448
484
 
449
485
  // src/tools/dashboard.ts
450
- import { z as z4 } from "zod";
486
+ import { z as z5 } from "zod";
451
487
  import { listEntities as listEntities2 } from "@stackwright-pro/cli-data-explorer";
452
488
  function registerDashboardTools(server2) {
453
489
  server2.tool(
454
490
  "stackwright_pro_generate_dashboard",
455
491
  "Generate a dashboard page configuration for displaying API data. Creates YAML content for a Stackwright page with grid, metric_card, data_table, and collection_list content types. Use this after stackwright_pro_generate_filter to create pages for your API collections.",
456
492
  {
457
- entities: z4.array(z4.string()).describe("Entity slugs to include in dashboard"),
458
- layout: z4.enum(["grid", "table", "mixed"]).optional().describe("Dashboard layout style"),
459
- pageTitle: z4.string().optional().describe("Page title"),
460
- specPath: z4.string().optional().describe("Path to OpenAPI spec for entity details")
493
+ entities: jsonCoerce(z5.array(z5.string())).describe("Entity slugs to include in dashboard"),
494
+ layout: z5.enum(["grid", "table", "mixed"]).optional().describe("Dashboard layout style"),
495
+ pageTitle: z5.string().optional().describe("Page title"),
496
+ specPath: z5.string().optional().describe("Path to OpenAPI spec for entity details")
461
497
  },
462
498
  async ({ entities, layout = "mixed", pageTitle, specPath }) => {
463
499
  let entityDetails = [];
@@ -543,9 +579,9 @@ ${yaml}
543
579
  "stackwright_pro_generate_detail_page",
544
580
  "Generate a detail view page for a single API entity. Creates YAML content for displaying all fields of an individual record. Use this to create detail pages that complement collection listing pages.",
545
581
  {
546
- entity: z4.string().describe('Entity slug (e.g., "equipment")'),
547
- slugField: z4.string().optional().describe('Field to use as URL slug (default: "id")'),
548
- specPath: z4.string().optional().describe("Path to OpenAPI spec for field details")
582
+ entity: z5.string().describe('Entity slug (e.g., "equipment")'),
583
+ slugField: z5.string().optional().describe('Field to use as URL slug (default: "id")'),
584
+ specPath: z5.string().optional().describe("Path to OpenAPI spec for field details")
549
585
  },
550
586
  async ({ entity, slugField = "id", specPath }) => {
551
587
  let fields = [];
@@ -568,39 +604,49 @@ ${yaml}
568
604
  ` title: "${entityName} Details | {{ ${entity}.${slugField} }}"`,
569
605
  "",
570
606
  " content_items:",
571
- " - main:",
572
- ' label: "detail-header"',
573
- " heading:",
574
- ` text: "{{ ${entity}.${slugField} }}"`,
575
- ' textSize: "h1"',
576
- " textBlocks:",
577
- ` - text: "Details for this ${entity}"`,
578
- ' textSize: "body1"',
579
- ' background: "primary"',
580
- ' color: "text"',
607
+ " - type: text_block",
608
+ ' label: "detail-header"',
609
+ " heading:",
610
+ ` text: "{{ ${entity}.${slugField} }}"`,
611
+ ' textSize: "h1"',
612
+ " textBlocks:",
613
+ ` - text: "Details for this ${entity}"`,
614
+ ' textSize: "body1"',
581
615
  "",
582
- " - grid:",
583
- ' label: "detail-fields"',
584
- " columns: 2",
585
- " items:"
616
+ " - type: grid",
617
+ ' label: "detail-fields"',
618
+ " columns:"
586
619
  ];
587
620
  const displayFields = fields.length > 0 ? fields.slice(0, 8) : [
588
621
  { name: slugField, type: "string" },
589
622
  { name: "created_at", type: "datetime" },
590
623
  { name: "updated_at", type: "datetime" }
591
624
  ];
592
- for (const field of displayFields) {
593
- const label = field.name.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
594
- yamlLines.push(` - card:`);
595
- yamlLines.push(` label: "${field.name}-field"`);
596
- yamlLines.push(` heading:`);
597
- yamlLines.push(` text: "${label}"`);
598
- yamlLines.push(` textSize: "h4"`);
599
- yamlLines.push(` content:`);
600
- yamlLines.push(` - text: "{{ ${entity}.${field.name} }}"`);
601
- yamlLines.push(` textSize: "body1"`);
602
- }
603
- yamlLines.push(' background: "background"');
625
+ const mid = Math.ceil(displayFields.length / 2);
626
+ const col1 = displayFields.slice(0, mid);
627
+ const col2 = displayFields.slice(mid);
628
+ for (const [colIndex, colFields] of [col1, col2].entries()) {
629
+ yamlLines.push(" - width: 1");
630
+ yamlLines.push(" content_items:");
631
+ for (const field of colFields) {
632
+ const label = field.name.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
633
+ yamlLines.push(" - type: text_block");
634
+ yamlLines.push(` label: "${field.name}-field"`);
635
+ yamlLines.push(" heading:");
636
+ yamlLines.push(` text: "${label}"`);
637
+ yamlLines.push(' textSize: "h4"');
638
+ yamlLines.push(" textBlocks:");
639
+ yamlLines.push(` - text: "{{ ${entity}.${field.name} }}"`);
640
+ yamlLines.push(' textSize: "body1"');
641
+ }
642
+ }
643
+ yamlLines.push("");
644
+ yamlLines.push(" - type: action_bar");
645
+ yamlLines.push(" actions:");
646
+ yamlLines.push(` - label: "<- Back to ${entityName}"`);
647
+ yamlLines.push(" action: navigate");
648
+ yamlLines.push(` href: "/${entity}"`);
649
+ yamlLines.push(" style: secondary");
604
650
  const yaml = yamlLines.join("\n");
605
651
  return {
606
652
  content: [
@@ -621,7 +667,7 @@ ${yaml}
621
667
  }
622
668
 
623
669
  // src/tools/clarification.ts
624
- import { z as z5 } from "zod";
670
+ import { z as z6 } from "zod";
625
671
  var CONTRADICTION_PATTERNS = [
626
672
  {
627
673
  keywords: ["minimal", "clean", "simple"],
@@ -709,12 +755,14 @@ function registerClarificationTools(server2) {
709
755
  "stackwright_pro_clarify",
710
756
  "Ask the user for clarification when a specialist otter encounters ambiguity. This is for MID-EXECUTION questions, NOT upfront question collection (use the Question Manifest Protocol for that). Returns a structured response for the foreman to present to the user via ask_user_question (closed_choice) or directly (open_text).",
711
757
  {
712
- context: z5.string().optional().describe("Context about what the otter is trying to do"),
713
- question_type: z5.enum(["closed_choice", "open_text"]).describe("Type of question being asked"),
714
- question: z5.string().describe("The clarification question to ask the user"),
715
- choices: z5.array(z5.string()).optional().describe("Options for closed_choice questions"),
716
- priority: z5.enum(["blocking", "preferred", "optional"]).optional().default("preferred").describe("How critical is this clarification? Default: preferred"),
717
- target_field: z5.string().optional().describe("What field/config does this clarify?")
758
+ context: z6.string().optional().describe("Context about what the otter is trying to do"),
759
+ question_type: z6.enum(["closed_choice", "open_text"]).describe("Type of question being asked"),
760
+ question: z6.string().describe("The clarification question to ask the user"),
761
+ choices: jsonCoerce(z6.array(z6.string()).optional()).describe(
762
+ "Options for closed_choice questions"
763
+ ),
764
+ priority: z6.enum(["blocking", "preferred", "optional"]).optional().default("preferred").describe("How critical is this clarification? Default: preferred"),
765
+ target_field: z6.string().optional().describe("What field/config does this clarify?")
718
766
  },
719
767
  async ({ context, question_type, question, choices, priority, target_field }) => {
720
768
  const result = handleClarify({
@@ -743,8 +791,10 @@ function registerClarificationTools(server2) {
743
791
  "stackwright_pro_detect_conflict",
744
792
  "Detect when a user's stated preference conflicts with their selected choices. Uses keyword heuristics against known contradiction patterns (minimal vs vibrant, dark vs light, etc). Returns conflict details and resolution options.",
745
793
  {
746
- stated_preference: z5.string().describe("What the user said they wanted"),
747
- selected_values: z5.record(z5.string(), z5.string()).describe("What the user actually selected (field \u2192 value)")
794
+ stated_preference: z6.string().describe("What the user said they wanted"),
795
+ selected_values: jsonCoerce(z6.record(z6.string(), z6.string())).describe(
796
+ "What the user actually selected (field \u2192 value)"
797
+ )
748
798
  },
749
799
  async ({ stated_preference, selected_values }) => {
750
800
  const result = handleDetectConflict({ stated_preference, selected_values });
@@ -789,7 +839,7 @@ ${result.resolution_options?.map((o) => ` \u2022 ${o}`).join("\n")}
789
839
  }
790
840
 
791
841
  // src/tools/packages.ts
792
- import { z as z6 } from "zod";
842
+ import { z as z7 } from "zod";
793
843
  import { readFileSync, writeFileSync, existsSync, realpathSync, lstatSync } from "fs";
794
844
  import { execSync } from "child_process";
795
845
  import path2 from "path";
@@ -810,17 +860,23 @@ function registerPackageTools(server2) {
810
860
  "Ensures pro packages are present in a project's package.json. Safe to call multiple times \u2014 never overwrites existing version pins. Use this to bootstrap dependencies before specialist otters run. Pass includeBaseline: true to automatically include all required @stackwright-pro/* baseline dependencies. Safe to call on existing projects \u2014 never overwrites pinned versions.",
811
861
  {
812
862
  // FIX 3 (B-new-1): Zod v4 requires two-arg z.record(keySchema, valueSchema)
813
- packages: z6.record(z6.string(), z6.string()).describe(
814
- 'Dependencies to add. Record<packageName, version>. e.g. { "@stackwright-pro/auth": "latest" }'
863
+ packages: jsonCoerce(z7.record(z7.string(), z7.string()).optional().default({})).describe(
864
+ 'Dependencies to add. Record<packageName, version>. e.g. { "@stackwright-pro/auth": "latest" }. Omit or pass {} when using includeBaseline: true.'
865
+ ),
866
+ devPackages: jsonCoerce(z7.record(z7.string(), z7.string()).optional()).describe(
867
+ "devDependencies to add. Same format as packages."
815
868
  ),
816
- devPackages: z6.record(z6.string(), z6.string()).optional().describe("devDependencies to add. Same format as packages."),
817
- scripts: z6.record(z6.string(), z6.string()).optional().describe("npm scripts to add. Only adds if key does not already exist."),
818
- targetDir: z6.string().optional().describe(
869
+ scripts: jsonCoerce(z7.record(z7.string(), z7.string()).optional()).describe(
870
+ "npm scripts to add. Only adds if key does not already exist."
871
+ ),
872
+ targetDir: z7.string().optional().describe(
819
873
  "Project directory containing package.json. Defaults to process.cwd(). Must be an absolute path within the current working directory."
820
874
  ),
821
- runInstall: z6.boolean().optional().default(true).describe("Run pnpm install after writing package.json. Defaults to true."),
822
- includeBaseline: z6.boolean().optional().default(false).describe(
823
- "When true, automatically merges BASELINE_DEPS and BASELINE_DEV_DEPS before applying packages/devPackages args. Safe to call on existing projects \u2014 never overwrites pinned versions."
875
+ runInstall: boolCoerce(z7.boolean().optional().default(true)).describe(
876
+ "Run pnpm install after writing package.json. Defaults to true. Pass boolean true/false."
877
+ ),
878
+ includeBaseline: boolCoerce(z7.boolean().optional().default(false)).describe(
879
+ "When true, automatically merges BASELINE_DEPS and BASELINE_DEV_DEPS before applying packages/devPackages args. Safe to call on existing projects \u2014 never overwrites pinned versions. Pass boolean true/false."
824
880
  )
825
881
  },
826
882
  async ({ packages, devPackages, scripts, targetDir, runInstall, includeBaseline }) => {
@@ -978,11 +1034,11 @@ function setupPackages(opts) {
978
1034
  }
979
1035
  }
980
1036
  const raw = readFileSync(realPackageJsonPath, "utf8");
981
- const PackageJsonSchema = z6.object({
1037
+ const PackageJsonSchema = z7.object({
982
1038
  // Zod v4: z.record(keySchema, valueSchema) — two-arg form required
983
- dependencies: z6.record(z6.string(), z6.string()).optional(),
984
- devDependencies: z6.record(z6.string(), z6.string()).optional(),
985
- scripts: z6.record(z6.string(), z6.string()).optional()
1039
+ dependencies: z7.record(z7.string(), z7.string()).optional(),
1040
+ devDependencies: z7.record(z7.string(), z7.string()).optional(),
1041
+ scripts: z7.record(z7.string(), z7.string()).optional()
986
1042
  }).passthrough();
987
1043
  const schemaResult = PackageJsonSchema.safeParse(JSON.parse(raw));
988
1044
  if (!schemaResult.success) {
@@ -1063,9 +1119,10 @@ function setupPackages(opts) {
1063
1119
  }
1064
1120
 
1065
1121
  // src/tools/questions.ts
1066
- import { readFile } from "fs/promises";
1122
+ import { readFile, writeFile } from "fs/promises";
1123
+ import { existsSync as existsSync2, lstatSync as lstatSync2, mkdirSync, renameSync } from "fs";
1067
1124
  import { join } from "path";
1068
- import { z as z7 } from "zod";
1125
+ import { z as z8 } from "zod";
1069
1126
 
1070
1127
  // src/question-adapter.ts
1071
1128
  function truncate(str, maxLength) {
@@ -1250,23 +1307,30 @@ function answersToManifestFormat(answers, questions) {
1250
1307
  return result;
1251
1308
  }
1252
1309
 
1310
+ // src/validation.ts
1311
+ var SAFE_PHASE = /^[a-z][a-z0-9-]{0,30}$/;
1312
+ var SAFE_QUESTION_ID = /^[a-z0-9][a-z0-9-]{0,63}$/i;
1313
+ function isValidPhase(phase) {
1314
+ return SAFE_PHASE.test(phase);
1315
+ }
1316
+
1253
1317
  // src/tools/questions.ts
1254
- var ManifestQuestionSchema = z7.object({
1255
- id: z7.string(),
1256
- question: z7.string(),
1257
- type: z7.enum(["text", "select", "multi-select", "confirm"]),
1258
- required: z7.boolean().optional(),
1259
- options: z7.array(
1260
- z7.object({
1261
- label: z7.string(),
1262
- value: z7.string()
1318
+ var ManifestQuestionSchema = z8.object({
1319
+ id: z8.string(),
1320
+ question: z8.string(),
1321
+ type: z8.enum(["text", "select", "multi-select", "confirm"]),
1322
+ required: z8.boolean().optional(),
1323
+ options: z8.array(
1324
+ z8.object({
1325
+ label: z8.string(),
1326
+ value: z8.string()
1263
1327
  })
1264
1328
  ).optional(),
1265
- default: z7.union([z7.string(), z7.boolean(), z7.array(z7.string())]).optional(),
1266
- help: z7.string().optional(),
1267
- dependsOn: z7.object({
1268
- questionId: z7.string(),
1269
- value: z7.union([z7.string(), z7.array(z7.string())])
1329
+ default: z8.union([z8.string(), z8.boolean(), z8.array(z8.string())]).optional(),
1330
+ help: z8.string().optional(),
1331
+ dependsOn: z8.object({
1332
+ questionId: z8.string(),
1333
+ value: z8.union([z8.string(), z8.array(z8.string())])
1270
1334
  }).optional()
1271
1335
  });
1272
1336
  function registerQuestionTools(server2) {
@@ -1274,15 +1338,16 @@ function registerQuestionTools(server2) {
1274
1338
  "stackwright_pro_present_phase_questions",
1275
1339
  "Adapt manifest-format questions from a specialist otter and present them to the user via ask_user_question. Pass only the phase name \u2014 this tool reads questions from .stackwright/question-manifest.json automatically. The questions parameter is optional and only needed if the manifest has not been written yet. Use this instead of calling ask_user_question directly \u2014 it handles label truncation (50-char limit), header generation, confirm/text defaults, and correct array formatting automatically. IMPORTANT: This is the ONLY approved way to prepare questions before calling ask_user_question. Never call ask_user_question with raw manifest questions. Never retry ask_user_question validation errors by calling it directly \u2014 always re-call this tool.",
1276
1340
  {
1277
- phase: z7.string().describe('Phase name for display context, e.g. "designer", "api", "auth"'),
1278
- questions: z7.array(ManifestQuestionSchema).optional().describe(
1341
+ phase: z8.string().describe('Phase name for display context, e.g. "designer", "api", "auth"'),
1342
+ questions: jsonCoerce(z8.array(ManifestQuestionSchema).optional()).describe(
1279
1343
  "Questions in Question Manifest format. If omitted, questions are read from .stackwright/question-manifest.json using the phase name."
1280
1344
  ),
1281
- answers: z7.record(z7.union([z7.string(), z7.array(z7.string()), z7.boolean()])).optional().describe("Previously collected answers used to resolve dependsOn conditions")
1345
+ answers: jsonCoerce(
1346
+ z8.record(z8.string(), z8.union([z8.string(), z8.array(z8.string()), z8.boolean()])).optional()
1347
+ ).describe("Previously collected answers used to resolve dependsOn conditions")
1282
1348
  },
1283
1349
  async ({ phase, questions, answers }) => {
1284
1350
  let resolvedQuestions;
1285
- const SAFE_PHASE = /^[a-z][a-z0-9-]{0,30}$/;
1286
1351
  if (!SAFE_PHASE.test(phase)) {
1287
1352
  return {
1288
1353
  content: [
@@ -1329,17 +1394,36 @@ function registerQuestionTools(server2) {
1329
1394
  }
1330
1395
  }
1331
1396
  const adapted = adaptQuestions(resolvedQuestions, answers ?? {});
1397
+ const labelSchema = z8.string().max(50, "Value should have at most 50 characters");
1398
+ for (const q of adapted) {
1399
+ for (const opt of q.options) {
1400
+ const check = labelSchema.safeParse(opt.label);
1401
+ if (!check.success) {
1402
+ return {
1403
+ content: [
1404
+ {
1405
+ type: "text",
1406
+ text: JSON.stringify({
1407
+ error: `Option label for phase "${phase}" exceeds 50 characters: ${check.error.issues[0]?.message ?? "label too long"}. Truncate option labels to \u226450 characters before calling this tool.`
1408
+ })
1409
+ }
1410
+ ],
1411
+ isError: true
1412
+ };
1413
+ }
1414
+ }
1415
+ }
1332
1416
  if (adapted.length === 0) {
1333
1417
  return {
1334
1418
  content: [
1335
1419
  {
1336
1420
  type: "text",
1337
- text: JSON.stringify({
1338
- phase,
1339
- skipped: true,
1340
- reason: "No questions to present (all filtered by dependsOn conditions)",
1341
- answers: []
1342
- })
1421
+ text: `Phase "${phase}" has no questions to present. Do NOT call ask_user_question. Call stackwright_pro_save_phase_answers({ phase: "${phase}", rawAnswers: [] }) directly, then proceed to the execution step for this phase.`
1422
+ },
1423
+ {
1424
+ type: "text",
1425
+ // Empty array — second block always present so the foreman's two-block contract holds
1426
+ text: JSON.stringify([])
1343
1427
  }
1344
1428
  ],
1345
1429
  isError: false
@@ -1360,11 +1444,147 @@ function registerQuestionTools(server2) {
1360
1444
  };
1361
1445
  }
1362
1446
  );
1447
+ server2.tool(
1448
+ "stackwright_pro_get_next_question",
1449
+ "Returns the next unanswered question for a phase as a plain JSON object \u2014 one question at a time. Returns { done: true } when all questions are answered or the phase has no questions. Call this in a loop: present the question in plain chat, get user reply, call record_answer, repeat.",
1450
+ { phase: z8.string().describe('Phase name e.g. "designer", "api", "auth"') },
1451
+ async ({ phase }) => {
1452
+ if (!SAFE_PHASE.test(phase)) {
1453
+ return {
1454
+ content: [
1455
+ {
1456
+ type: "text",
1457
+ text: JSON.stringify({ error: `Invalid phase name: "${phase.slice(0, 50)}"` })
1458
+ }
1459
+ ],
1460
+ isError: true
1461
+ };
1462
+ }
1463
+ const cwd = process.cwd();
1464
+ const questionsPath = join(cwd, ".stackwright", "questions", `${phase}.json`);
1465
+ let questions = [];
1466
+ try {
1467
+ const raw = await readFile(questionsPath, "utf-8");
1468
+ const parsed = JSON.parse(raw);
1469
+ questions = parsed.questions ?? [];
1470
+ } catch {
1471
+ return { content: [{ type: "text", text: JSON.stringify({ done: true }) }] };
1472
+ }
1473
+ if (questions.length === 0) {
1474
+ return { content: [{ type: "text", text: JSON.stringify({ done: true }) }] };
1475
+ }
1476
+ const answersPath = join(cwd, ".stackwright", "answers", `${phase}.json`);
1477
+ let answeredIds = /* @__PURE__ */ new Set();
1478
+ try {
1479
+ const raw = await readFile(answersPath, "utf-8");
1480
+ const parsed = JSON.parse(raw);
1481
+ answeredIds = new Set(Object.keys(parsed.answers ?? {}));
1482
+ } catch {
1483
+ }
1484
+ const next = questions.find((q) => !answeredIds.has(q.id));
1485
+ if (!next) {
1486
+ return { content: [{ type: "text", text: JSON.stringify({ done: true }) }] };
1487
+ }
1488
+ return {
1489
+ content: [
1490
+ {
1491
+ type: "text",
1492
+ text: JSON.stringify({
1493
+ done: false,
1494
+ questionId: next.id,
1495
+ question: next.question,
1496
+ type: next.type,
1497
+ options: next.options ?? null,
1498
+ help: next.help ?? null,
1499
+ index: questions.indexOf(next) + 1,
1500
+ total: questions.length
1501
+ })
1502
+ }
1503
+ ]
1504
+ };
1505
+ }
1506
+ );
1507
+ server2.tool(
1508
+ "stackwright_pro_record_answer",
1509
+ "Records a single answer to a phase question. Appends to .stackwright/answers/{phase}.json incrementally. Idempotent \u2014 calling twice for the same questionId overwrites. Call after receiving each user reply in the conversational question loop.",
1510
+ {
1511
+ phase: z8.string().describe('Phase name e.g. "designer"'),
1512
+ questionId: z8.string().describe('The question ID from get_next_question, e.g. "designer-1"'),
1513
+ answer: z8.string().describe("The user's free-text answer")
1514
+ },
1515
+ async ({ phase, questionId, answer }) => {
1516
+ if (!SAFE_PHASE.test(phase)) {
1517
+ return {
1518
+ content: [
1519
+ { type: "text", text: JSON.stringify({ error: "Invalid phase name" }) }
1520
+ ],
1521
+ isError: true
1522
+ };
1523
+ }
1524
+ if (!SAFE_QUESTION_ID.test(questionId)) {
1525
+ return {
1526
+ content: [
1527
+ { type: "text", text: JSON.stringify({ error: "Invalid questionId" }) }
1528
+ ],
1529
+ isError: true
1530
+ };
1531
+ }
1532
+ const safeAnswer = answer.slice(0, 2e3);
1533
+ const cwd = process.cwd();
1534
+ const answersDir = join(cwd, ".stackwright", "answers");
1535
+ const answersPath = join(answersDir, `${phase}.json`);
1536
+ if (existsSync2(answersDir) && lstatSync2(answersDir).isSymbolicLink()) {
1537
+ return {
1538
+ content: [
1539
+ {
1540
+ type: "text",
1541
+ text: JSON.stringify({ error: "answers dir is a symlink \u2014 refusing to write" })
1542
+ }
1543
+ ],
1544
+ isError: true
1545
+ };
1546
+ }
1547
+ mkdirSync(answersDir, { recursive: true });
1548
+ let existing = {
1549
+ version: "1.0",
1550
+ phase,
1551
+ answers: {}
1552
+ };
1553
+ try {
1554
+ if (existsSync2(answersPath)) {
1555
+ if (lstatSync2(answersPath).isSymbolicLink()) {
1556
+ return {
1557
+ content: [
1558
+ {
1559
+ type: "text",
1560
+ text: JSON.stringify({ error: "answers file is a symlink \u2014 refusing to write" })
1561
+ }
1562
+ ],
1563
+ isError: true
1564
+ };
1565
+ }
1566
+ const raw = await readFile(answersPath, "utf-8");
1567
+ existing = JSON.parse(raw);
1568
+ }
1569
+ } catch {
1570
+ }
1571
+ existing.answers[questionId] = safeAnswer;
1572
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1573
+ const tmp = `${answersPath}.tmp`;
1574
+ await writeFile(tmp, JSON.stringify(existing, null, 2), "utf-8");
1575
+ renameSync(tmp, answersPath);
1576
+ return {
1577
+ content: [
1578
+ { type: "text", text: JSON.stringify({ recorded: true, phase, questionId }) }
1579
+ ]
1580
+ };
1581
+ }
1582
+ );
1363
1583
  }
1364
1584
 
1365
1585
  // src/tools/orchestration.ts
1366
- import { z as z8 } from "zod";
1367
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync, lstatSync as lstatSync2 } from "fs";
1586
+ import { z as z9 } from "zod";
1587
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync2, lstatSync as lstatSync3 } from "fs";
1368
1588
  import { join as join2 } from "path";
1369
1589
  var OTTER_NAME_TO_PHASE = [
1370
1590
  ["designer", "designer"],
@@ -1374,7 +1594,9 @@ var OTTER_NAME_TO_PHASE = [
1374
1594
  ["dashboard", "dashboard"],
1375
1595
  ["data", "data"],
1376
1596
  ["page", "pages"],
1377
- ["workflow", "workflow"]
1597
+ ["workflow", "workflow"],
1598
+ ["polish", "polish"],
1599
+ ["geo", "geo"]
1378
1600
  ];
1379
1601
  var PHASE_TO_OTTER = {
1380
1602
  designer: "stackwright-pro-designer-otter",
@@ -1384,7 +1606,9 @@ var PHASE_TO_OTTER = {
1384
1606
  pages: "stackwright-pro-page-otter",
1385
1607
  dashboard: "stackwright-pro-dashboard-otter",
1386
1608
  data: "stackwright-pro-data-otter",
1387
- workflow: "stackwright-pro-workflow-otter"
1609
+ workflow: "stackwright-pro-workflow-otter",
1610
+ polish: "stackwright-pro-polish-otter",
1611
+ geo: "stackwright-pro-geo-otter"
1388
1612
  };
1389
1613
  function detectPhase(otterName) {
1390
1614
  const lower = otterName.toLowerCase();
@@ -1420,9 +1644,9 @@ function handleSaveManifest(input) {
1420
1644
  const dir = join2(cwd, ".stackwright");
1421
1645
  const filePath = join2(dir, "question-manifest.json");
1422
1646
  try {
1423
- mkdirSync(dir, { recursive: true });
1424
- if (existsSync2(filePath)) {
1425
- const stat = lstatSync2(filePath);
1647
+ mkdirSync2(dir, { recursive: true });
1648
+ if (existsSync3(filePath)) {
1649
+ const stat = lstatSync3(filePath);
1426
1650
  if (stat.isSymbolicLink()) {
1427
1651
  const message = `Refusing to write to symlink: ${filePath}`;
1428
1652
  return {
@@ -1450,14 +1674,78 @@ function handleSaveManifest(input) {
1450
1674
  }
1451
1675
  }
1452
1676
  function handleSavePhaseAnswers(input) {
1677
+ if (!isValidPhase(input.phase)) {
1678
+ return {
1679
+ text: JSON.stringify({
1680
+ success: false,
1681
+ error: `Invalid phase name: "${input.phase.slice(0, 50)}". Must be lowercase alphanumeric with hyphens, max 31 chars.`
1682
+ }),
1683
+ isError: true
1684
+ };
1685
+ }
1686
+ for (let i = 0; i < input.rawAnswers.length; i++) {
1687
+ const a = input.rawAnswers[i];
1688
+ if (!a.question_header || a.question_header.trim().length === 0) {
1689
+ return {
1690
+ text: JSON.stringify({
1691
+ success: false,
1692
+ error: `rawAnswers[${i}].question_header is empty \u2014 every answer must have a non-empty question_header.`
1693
+ }),
1694
+ isError: true
1695
+ };
1696
+ }
1697
+ if (!a.selected_options || a.selected_options.length === 0) {
1698
+ return {
1699
+ text: JSON.stringify({
1700
+ success: false,
1701
+ error: `rawAnswers[${i}].selected_options is empty for question_header "${a.question_header}" \u2014 every answer must have at least one selected option.`
1702
+ }),
1703
+ isError: true
1704
+ };
1705
+ }
1706
+ }
1707
+ if (input.questions && input.questions.length > 0 && input.rawAnswers.length === 0) {
1708
+ return {
1709
+ text: JSON.stringify({
1710
+ success: false,
1711
+ error: `${input.questions.length} questions provided but rawAnswers is empty \u2014 this is a malformed payload. If the phase truly has no questions, omit the questions parameter.`
1712
+ }),
1713
+ isError: true
1714
+ };
1715
+ }
1453
1716
  const cwd = input._cwd ?? process.cwd();
1454
1717
  const dir = join2(cwd, ".stackwright", "answers");
1455
1718
  const filePath = join2(dir, `${input.phase}.json`);
1456
1719
  try {
1457
- mkdirSync(dir, { recursive: true });
1720
+ mkdirSync2(dir, { recursive: true });
1721
+ const warnings = [];
1458
1722
  let answers;
1459
1723
  if (input.questions && input.questions.length > 0) {
1460
- answers = answersToManifestFormat(input.rawAnswers, input.questions);
1724
+ try {
1725
+ answers = answersToManifestFormat(input.rawAnswers, input.questions);
1726
+ } catch (mapErr) {
1727
+ answers = {};
1728
+ warnings.push(
1729
+ `Reverse-mapping threw (${mapErr instanceof Error ? mapErr.message : String(mapErr)}) \u2014 falling back to direct key mapping.`
1730
+ );
1731
+ }
1732
+ if (Object.keys(answers).length === 0 && input.rawAnswers.length > 0) {
1733
+ answers = Object.fromEntries(
1734
+ input.rawAnswers.map((a) => [
1735
+ a.question_header,
1736
+ a.selected_options.length > 1 ? a.selected_options : a.selected_options[0] ?? ""
1737
+ ])
1738
+ );
1739
+ warnings.push(
1740
+ `Reverse-mapping produced empty answers despite ${input.rawAnswers.length} rawAnswers \u2014 used direct key mapping as fallback. Ensure question_header values match the manifest header format (e.g. "DESI-1", not descriptive keys).`
1741
+ );
1742
+ }
1743
+ const requiredCount = input.questions.filter((q) => q.required !== false).length;
1744
+ if (input.rawAnswers.length < requiredCount) {
1745
+ warnings.push(
1746
+ `Only ${input.rawAnswers.length} answers provided for ${requiredCount} required questions (${input.questions.length} total) \u2014 answers may be incomplete.`
1747
+ );
1748
+ }
1461
1749
  } else {
1462
1750
  answers = Object.fromEntries(
1463
1751
  input.rawAnswers.map((a) => [a.question_header, a.selected_options[0] ?? ""])
@@ -1469,8 +1757,8 @@ function handleSavePhaseAnswers(input) {
1469
1757
  completedAt: (/* @__PURE__ */ new Date()).toISOString(),
1470
1758
  answers
1471
1759
  };
1472
- if (existsSync2(filePath)) {
1473
- const stat = lstatSync2(filePath);
1760
+ if (existsSync3(filePath)) {
1761
+ const stat = lstatSync3(filePath);
1474
1762
  if (stat.isSymbolicLink()) {
1475
1763
  const message = `Refusing to write to symlink: ${filePath}`;
1476
1764
  return {
@@ -1484,7 +1772,8 @@ function handleSavePhaseAnswers(input) {
1484
1772
  text: JSON.stringify({
1485
1773
  success: true,
1486
1774
  path: filePath,
1487
- answersCount: Object.keys(answers).length
1775
+ answersCount: Object.keys(answers).length,
1776
+ ...warnings.length > 0 ? { warnings } : {}
1488
1777
  }),
1489
1778
  isError: false
1490
1779
  };
@@ -1499,7 +1788,7 @@ function handleSavePhaseAnswers(input) {
1499
1788
  function handleReadPhaseAnswers(input) {
1500
1789
  const cwd = input._cwd ?? process.cwd();
1501
1790
  const filePath = join2(cwd, ".stackwright", "answers", `${input.phase}.json`);
1502
- if (!existsSync2(filePath)) {
1791
+ if (!existsSync3(filePath)) {
1503
1792
  return {
1504
1793
  text: JSON.stringify({ missing: true, phase: input.phase }),
1505
1794
  isError: false
@@ -1531,13 +1820,63 @@ function handleGetOtterName(input) {
1531
1820
  isError: false
1532
1821
  };
1533
1822
  }
1823
+ function handleSaveBuildContext(input) {
1824
+ const cwd = input._cwd ?? process.cwd();
1825
+ const dir = join2(cwd, ".stackwright");
1826
+ const filePath = join2(dir, "build-context.json");
1827
+ try {
1828
+ mkdirSync2(dir, { recursive: true });
1829
+ if (existsSync3(filePath)) {
1830
+ const stat = lstatSync3(filePath);
1831
+ if (stat.isSymbolicLink()) {
1832
+ return {
1833
+ text: JSON.stringify({
1834
+ success: false,
1835
+ error: `Refusing to write to symlink: ${filePath}`
1836
+ }),
1837
+ isError: true
1838
+ };
1839
+ }
1840
+ }
1841
+ const payload = {
1842
+ version: "1.0",
1843
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
1844
+ buildContext: input.buildContext
1845
+ };
1846
+ writeFileSync2(filePath, JSON.stringify(payload, null, 2) + "\n");
1847
+ return {
1848
+ text: JSON.stringify({ success: true, path: filePath }),
1849
+ isError: false
1850
+ };
1851
+ } catch (err) {
1852
+ const message = err instanceof Error ? err.message : String(err);
1853
+ return {
1854
+ text: JSON.stringify({ success: false, error: message }),
1855
+ isError: true
1856
+ };
1857
+ }
1858
+ }
1534
1859
  function registerOrchestrationTools(server2) {
1860
+ server2.tool(
1861
+ "stackwright_pro_save_build_context",
1862
+ `Save the user's initial build description to .stackwright/build-context.json. Call this once at startup after the user answers the opening "what are you building" question. The saved context is automatically prepended to specialist prompts by stackwright_pro_build_specialist_prompt.`,
1863
+ {
1864
+ buildContext: z9.string().describe("Free-text description of what the user wants to build")
1865
+ },
1866
+ async ({ buildContext }) => {
1867
+ const { text, isError } = handleSaveBuildContext({ buildContext });
1868
+ return {
1869
+ content: [{ type: "text", text }],
1870
+ isError
1871
+ };
1872
+ }
1873
+ );
1535
1874
  server2.tool(
1536
1875
  "stackwright_pro_parse_otter_response",
1537
1876
  "Parse and validate a specialist otter's QUESTION_COLLECTION_MODE JSON response. Handles JSON extraction from LLM responses (strips markdown, fixes single quotes, trailing commas). Detects the phase from the otter name. Use this immediately after invoke_agent() to get a validated manifest phase object.",
1538
1877
  {
1539
- otterName: z8.string().describe('The agent name, e.g. "stackwright-pro-api-otter"'),
1540
- responseText: z8.string().describe("Raw text response from the otter's QUESTION_COLLECTION_MODE invocation")
1878
+ otterName: z9.string().describe('The agent name, e.g. "stackwright-pro-api-otter"'),
1879
+ responseText: z9.string().describe("Raw text response from the otter's QUESTION_COLLECTION_MODE invocation")
1541
1880
  },
1542
1881
  async ({ otterName, responseText }) => {
1543
1882
  const { result, isError } = handleParseOtterResponse({ otterName, responseText });
@@ -1551,16 +1890,18 @@ function registerOrchestrationTools(server2) {
1551
1890
  "stackwright_pro_save_manifest",
1552
1891
  "Write the question manifest to .stackwright/question-manifest.json. Call this after collecting and parsing questions from all otters via stackwright_pro_parse_otter_response.",
1553
1892
  {
1554
- phases: z8.array(
1555
- z8.object({
1556
- phase: z8.string(),
1557
- otter: z8.string(),
1558
- questions: z8.array(z8.any()),
1559
- requiredPackages: z8.object({
1560
- dependencies: z8.record(z8.string(), z8.string()).optional(),
1561
- devPackages: z8.record(z8.string(), z8.string()).optional()
1562
- }).optional()
1563
- })
1893
+ phases: jsonCoerce(
1894
+ z9.array(
1895
+ z9.object({
1896
+ phase: z9.string(),
1897
+ otter: z9.string(),
1898
+ questions: z9.array(z9.any()),
1899
+ requiredPackages: z9.object({
1900
+ dependencies: z9.record(z9.string(), z9.string()).optional(),
1901
+ devPackages: z9.record(z9.string(), z9.string()).optional()
1902
+ }).optional()
1903
+ })
1904
+ )
1564
1905
  ).describe("Array of parsed phase objects from stackwright_pro_parse_otter_response")
1565
1906
  },
1566
1907
  async ({ phases }) => {
@@ -1575,15 +1916,37 @@ function registerOrchestrationTools(server2) {
1575
1916
  "stackwright_pro_save_phase_answers",
1576
1917
  "Save user answers for a phase to .stackwright/answers/{phase}.json. Pass rawAnswers directly from ask_user_question and the original manifest questions for label-to-value reverse mapping.",
1577
1918
  {
1578
- phase: z8.string().describe('Phase name, e.g. "designer"'),
1579
- rawAnswers: z8.array(
1580
- z8.object({
1581
- question_header: z8.string(),
1582
- selected_options: z8.array(z8.string()),
1583
- other_text: z8.string().nullable().optional()
1584
- })
1585
- ).describe("Answers as returned by ask_user_question"),
1586
- questions: z8.array(z8.any()).optional().describe("Original manifest questions for label\u2192value reverse-mapping")
1919
+ phase: z9.string().describe('Phase name, e.g. "designer"'),
1920
+ rawAnswers: jsonCoerce(
1921
+ z9.array(
1922
+ z9.object({
1923
+ question_header: z9.string().min(1, "question_header must not be empty"),
1924
+ selected_options: z9.array(z9.string()).min(1, "selected_options must have at least one entry"),
1925
+ other_text: z9.string().nullable().optional()
1926
+ })
1927
+ )
1928
+ ).describe(
1929
+ "Answers as returned by ask_user_question \u2014 pass the native array, not a JSON string"
1930
+ ),
1931
+ questions: jsonCoerce(
1932
+ z9.array(
1933
+ z9.object({
1934
+ id: z9.string(),
1935
+ question: z9.string(),
1936
+ type: z9.string(),
1937
+ options: z9.array(z9.object({ label: z9.string(), value: z9.string() })).optional(),
1938
+ required: z9.boolean().optional(),
1939
+ default: z9.union([z9.string(), z9.boolean(), z9.array(z9.string())]).optional(),
1940
+ help: z9.string().optional(),
1941
+ dependsOn: z9.object({
1942
+ questionId: z9.string(),
1943
+ value: z9.union([z9.string(), z9.array(z9.string())])
1944
+ }).optional()
1945
+ }).passthrough()
1946
+ ).optional()
1947
+ ).describe(
1948
+ "Original manifest questions for label\u2192value reverse-mapping \u2014 pass the native array, not a JSON string"
1949
+ )
1587
1950
  },
1588
1951
  async ({ phase, rawAnswers, questions }) => {
1589
1952
  const { text, isError } = handleSavePhaseAnswers({
@@ -1601,7 +1964,7 @@ function registerOrchestrationTools(server2) {
1601
1964
  "stackwright_pro_read_phase_answers",
1602
1965
  "Read saved answers for a phase from .stackwright/answers/{phase}.json. Returns { missing: true } when no answers exist yet \u2014 use this to skip phases safely in the execution loop.",
1603
1966
  {
1604
- phase: z8.string().describe('Phase name, e.g. "designer"')
1967
+ phase: z9.string().describe('Phase name, e.g. "designer"')
1605
1968
  },
1606
1969
  async ({ phase }) => {
1607
1970
  const { text, isError } = handleReadPhaseAnswers({ phase });
@@ -1615,7 +1978,7 @@ function registerOrchestrationTools(server2) {
1615
1978
  "stackwright_pro_get_otter_name",
1616
1979
  "Get the agent name for a phase (e.g. 'designer' \u2192 'stackwright-pro-designer-otter'). Use this in the execution loop to invoke the correct specialist otter without hardcoding names in the prompt.",
1617
1980
  {
1618
- phase: z8.string().describe('Phase name, e.g. "designer", "api", "pages"')
1981
+ phase: z9.string().describe('Phase name, e.g. "designer", "api", "pages"')
1619
1982
  },
1620
1983
  async ({ phase }) => {
1621
1984
  const { text, isError } = handleGetOtterName({ phase });
@@ -1628,52 +1991,422 @@ function registerOrchestrationTools(server2) {
1628
1991
  }
1629
1992
 
1630
1993
  // src/tools/pipeline.ts
1631
- import { z as z9 } from "zod";
1632
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync3, mkdirSync as mkdirSync2, lstatSync as lstatSync3 } from "fs";
1994
+ import { z as z11 } from "zod";
1995
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync5, mkdirSync as mkdirSync4, lstatSync as lstatSync5 } from "fs";
1996
+ import { join as join4 } from "path";
1997
+ import { createHash as createHash3 } from "crypto";
1998
+
1999
+ // src/artifact-signing.ts
2000
+ import {
2001
+ createHash as createHash2,
2002
+ generateKeyPairSync,
2003
+ createPublicKey,
2004
+ createPrivateKey,
2005
+ sign,
2006
+ verify,
2007
+ timingSafeEqual
2008
+ } from "crypto";
2009
+ import {
2010
+ readFileSync as readFileSync3,
2011
+ writeFileSync as writeFileSync3,
2012
+ existsSync as existsSync4,
2013
+ mkdirSync as mkdirSync3,
2014
+ lstatSync as lstatSync4,
2015
+ unlinkSync,
2016
+ readdirSync
2017
+ } from "fs";
1633
2018
  import { join as join3 } from "path";
1634
- var PHASE_ORDER = [
1635
- "designer",
1636
- "theme",
1637
- "api",
1638
- "auth",
1639
- "data",
1640
- "pages",
1641
- "dashboard",
1642
- "workflow"
1643
- ];
1644
- var PHASE_DEPENDENCIES = {
1645
- designer: [],
1646
- theme: ["designer"],
1647
- api: [],
1648
- auth: [],
1649
- data: ["api"],
1650
- pages: ["designer", "theme", "api", "data", "auth"],
1651
- dashboard: ["designer", "theme", "api", "data"],
1652
- workflow: ["auth"]
1653
- };
1654
- var PHASE_ARTIFACT = {
1655
- designer: "design-language.json",
1656
- theme: "theme-tokens.json",
1657
- api: "api-config.json",
1658
- auth: "auth-config.json",
1659
- data: "data-config.json",
1660
- pages: "pages-manifest.json",
1661
- dashboard: "dashboard-manifest.json",
1662
- workflow: "workflow-config.json"
1663
- };
1664
- var PHASE_TO_OTTER2 = {
1665
- designer: "stackwright-pro-designer-otter",
1666
- theme: "stackwright-pro-theme-otter",
1667
- api: "stackwright-pro-api-otter",
1668
- auth: "stackwright-pro-auth-otter",
1669
- data: "stackwright-pro-data-otter",
1670
- pages: "stackwright-pro-page-otter",
1671
- dashboard: "stackwright-pro-dashboard-otter",
1672
- workflow: "stackwright-pro-workflow-otter"
1673
- };
1674
- function isValidPhase(phase) {
1675
- return PHASE_ORDER.includes(phase);
1676
- }
2019
+ import { z as z10 } from "zod";
2020
+ var ALGORITHM = "ECDSA-P384-SHA384";
2021
+ var KEY_FILE = "pipeline-keys.json";
2022
+ var KEY_DIR = ".stackwright";
2023
+ var SIGNATURE_MANIFEST = "signatures.json";
2024
+ var ARTIFACTS_DIR = ".stackwright/artifacts";
2025
+ function rejectSymlink(filePath, context) {
2026
+ if (!existsSync4(filePath)) return;
2027
+ const stat = lstatSync4(filePath);
2028
+ if (stat.isSymbolicLink()) {
2029
+ throw new Error(`Security: refusing to follow symlink at ${context}: ${filePath}`);
2030
+ }
2031
+ }
2032
+ function computeSha384(data) {
2033
+ return createHash2("sha384").update(data).digest("hex");
2034
+ }
2035
+ function safeDigestEqual(a, b) {
2036
+ if (a.length !== b.length) return false;
2037
+ return timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
2038
+ }
2039
+ function emptyManifest() {
2040
+ return {
2041
+ version: "1.0",
2042
+ algorithm: ALGORITHM,
2043
+ signatures: {}
2044
+ };
2045
+ }
2046
+ function initPipelineKeys(cwd) {
2047
+ const keyDir = join3(cwd, KEY_DIR);
2048
+ const keyPath = join3(keyDir, KEY_FILE);
2049
+ rejectSymlink(keyPath, "pipeline-keys.json");
2050
+ mkdirSync3(keyDir, { recursive: true });
2051
+ const { publicKey, privateKey } = generateKeyPairSync("ec", {
2052
+ namedCurve: "P-384"
2053
+ });
2054
+ const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
2055
+ const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
2056
+ const fingerprint = createHash2("sha256").update(publicKeyPem).digest("hex");
2057
+ const keyFile = {
2058
+ version: "1.0",
2059
+ algorithm: ALGORITHM,
2060
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2061
+ publicKeyPem,
2062
+ privateKeyPem
2063
+ };
2064
+ writeFileSync3(keyPath, JSON.stringify(keyFile, null, 2), { encoding: "utf-8" });
2065
+ return { publicKeyPem, fingerprint };
2066
+ }
2067
+ function loadPipelineKeys(cwd) {
2068
+ const keyPath = join3(cwd, KEY_DIR, KEY_FILE);
2069
+ rejectSymlink(keyPath, "pipeline-keys.json");
2070
+ if (!existsSync4(keyPath)) {
2071
+ throw new Error("Pipeline keys not found \u2014 call initPipelineKeys() first");
2072
+ }
2073
+ let raw;
2074
+ try {
2075
+ raw = readFileSync3(keyPath, "utf-8");
2076
+ } catch (err) {
2077
+ const msg = err instanceof Error ? err.message : String(err);
2078
+ throw new Error(`Cannot read pipeline keys: ${msg}`, { cause: err });
2079
+ }
2080
+ let parsed;
2081
+ try {
2082
+ parsed = JSON.parse(raw);
2083
+ } catch {
2084
+ throw new Error("Pipeline keys file is not valid JSON");
2085
+ }
2086
+ if (typeof parsed.publicKeyPem !== "string" || !parsed.publicKeyPem.includes("-----BEGIN PUBLIC KEY-----")) {
2087
+ throw new Error("Invalid public key PEM in pipeline keys file");
2088
+ }
2089
+ if (typeof parsed.privateKeyPem !== "string" || !parsed.privateKeyPem.includes("-----BEGIN")) {
2090
+ throw new Error("Invalid private key PEM in pipeline keys file");
2091
+ }
2092
+ const publicKey = createPublicKey(parsed.publicKeyPem);
2093
+ const privateKey = createPrivateKey(parsed.privateKeyPem);
2094
+ return { privateKey, publicKey };
2095
+ }
2096
+ function signArtifact(artifactBytes, privateKey) {
2097
+ const digest = computeSha384(artifactBytes);
2098
+ const sig = sign("SHA384", artifactBytes, privateKey);
2099
+ return {
2100
+ digest,
2101
+ signature: sig.toString("base64"),
2102
+ algorithm: ALGORITHM,
2103
+ signedAt: (/* @__PURE__ */ new Date()).toISOString()
2104
+ };
2105
+ }
2106
+ function verifyArtifact(artifactBytes, signature, publicKey) {
2107
+ const sigValid = verify(
2108
+ "SHA384",
2109
+ artifactBytes,
2110
+ publicKey,
2111
+ Buffer.from(signature.signature, "base64")
2112
+ );
2113
+ if (!sigValid) return false;
2114
+ const actualDigest = computeSha384(artifactBytes);
2115
+ return safeDigestEqual(actualDigest, signature.digest);
2116
+ }
2117
+ function loadSignatureManifest(cwd) {
2118
+ const manifestPath = join3(cwd, ARTIFACTS_DIR, SIGNATURE_MANIFEST);
2119
+ rejectSymlink(manifestPath, "signatures.json");
2120
+ if (!existsSync4(manifestPath)) return emptyManifest();
2121
+ let raw;
2122
+ try {
2123
+ raw = readFileSync3(manifestPath, "utf-8");
2124
+ } catch (err) {
2125
+ const msg = err instanceof Error ? err.message : String(err);
2126
+ throw new Error(`Cannot read signature manifest: ${msg}`, { cause: err });
2127
+ }
2128
+ try {
2129
+ const parsed = JSON.parse(raw);
2130
+ if (parsed.version !== "1.0" || typeof parsed.signatures !== "object") {
2131
+ throw new Error("Malformed signature manifest: invalid version or missing signatures object");
2132
+ }
2133
+ return parsed;
2134
+ } catch (err) {
2135
+ if (err instanceof Error && err.message.startsWith("Malformed")) throw err;
2136
+ throw new Error("Signature manifest is not valid JSON", { cause: err });
2137
+ }
2138
+ }
2139
+ function saveArtifactSignature(cwd, artifactFilename, sig, signerOtter) {
2140
+ const artifactsDir = join3(cwd, ARTIFACTS_DIR);
2141
+ const manifestPath = join3(artifactsDir, SIGNATURE_MANIFEST);
2142
+ rejectSymlink(manifestPath, "signatures.json (save)");
2143
+ mkdirSync3(artifactsDir, { recursive: true });
2144
+ const manifest = loadSignatureManifest(cwd);
2145
+ manifest.signatures[artifactFilename] = {
2146
+ ...sig,
2147
+ signedBy: signerOtter
2148
+ };
2149
+ writeFileSync3(manifestPath, JSON.stringify(manifest, null, 2), { encoding: "utf-8" });
2150
+ }
2151
+ function getArtifactSignature(cwd, artifactFilename) {
2152
+ const manifest = loadSignatureManifest(cwd);
2153
+ const entry = manifest.signatures[artifactFilename];
2154
+ if (!entry) return null;
2155
+ return {
2156
+ digest: entry.digest,
2157
+ signature: entry.signature,
2158
+ algorithm: entry.algorithm,
2159
+ signedAt: entry.signedAt
2160
+ };
2161
+ }
2162
+ function emitSignatureAuditEvent(params) {
2163
+ const record = JSON.stringify({
2164
+ level: "AUDIT",
2165
+ event: "ARTIFACT_SIGNATURE_FAIL",
2166
+ timestamp: params.timestamp,
2167
+ source: params.source,
2168
+ artifactFilename: params.artifactFilename,
2169
+ expectedDigest: params.expectedDigest,
2170
+ actualDigest: params.actualDigest,
2171
+ phase: params.phase
2172
+ });
2173
+ process.stderr.write(`ARTIFACT_SIGNATURE_FAIL ${record}
2174
+ `);
2175
+ }
2176
+ function registerArtifactSigningTools(server2) {
2177
+ server2.tool(
2178
+ "stackwright_pro_verify_artifact_signatures",
2179
+ "Verify ECDSA P-384 signatures for all pipeline artifacts in .stackwright/artifacts/. Auto-discovers keys from .stackwright/pipeline-keys.json. Returns per-artifact verification status.",
2180
+ {
2181
+ cwd: z10.string().optional().describe("Project root directory. Defaults to process.cwd().")
2182
+ },
2183
+ async ({ cwd: cwdParam }) => {
2184
+ const cwd = cwdParam ?? process.cwd();
2185
+ let publicKey;
2186
+ try {
2187
+ const keys = loadPipelineKeys(cwd);
2188
+ publicKey = keys.publicKey;
2189
+ } catch (err) {
2190
+ const msg = err instanceof Error ? err.message : String(err);
2191
+ return {
2192
+ content: [
2193
+ {
2194
+ type: "text",
2195
+ text: JSON.stringify({
2196
+ error: true,
2197
+ message: `Cannot load pipeline keys: ${msg}`
2198
+ })
2199
+ }
2200
+ ],
2201
+ isError: true
2202
+ };
2203
+ }
2204
+ let manifest;
2205
+ try {
2206
+ manifest = loadSignatureManifest(cwd);
2207
+ } catch (err) {
2208
+ const msg = err instanceof Error ? err.message : String(err);
2209
+ return {
2210
+ content: [
2211
+ {
2212
+ type: "text",
2213
+ text: JSON.stringify({
2214
+ error: true,
2215
+ message: `Cannot load signature manifest: ${msg}`
2216
+ })
2217
+ }
2218
+ ],
2219
+ isError: true
2220
+ };
2221
+ }
2222
+ const artifactsPath = join3(cwd, ARTIFACTS_DIR);
2223
+ let artifactFiles = [];
2224
+ try {
2225
+ if (existsSync4(artifactsPath)) {
2226
+ artifactFiles = readdirSync(artifactsPath).filter(
2227
+ (f) => f.endsWith(".json") && f !== SIGNATURE_MANIFEST
2228
+ );
2229
+ }
2230
+ } catch {
2231
+ }
2232
+ const results = [];
2233
+ let hasFailure = false;
2234
+ for (const filename of artifactFiles) {
2235
+ const filePath = join3(artifactsPath, filename);
2236
+ try {
2237
+ rejectSymlink(filePath, `artifact ${filename}`);
2238
+ } catch {
2239
+ results.push({
2240
+ filename,
2241
+ verified: false,
2242
+ error: "Refusing to verify symlink"
2243
+ });
2244
+ hasFailure = true;
2245
+ continue;
2246
+ }
2247
+ let artifactBytes;
2248
+ try {
2249
+ artifactBytes = readFileSync3(filePath);
2250
+ } catch (err) {
2251
+ const msg = err instanceof Error ? err.message : String(err);
2252
+ results.push({
2253
+ filename,
2254
+ verified: false,
2255
+ error: `Cannot read artifact: ${msg}`
2256
+ });
2257
+ hasFailure = true;
2258
+ continue;
2259
+ }
2260
+ const entry = manifest.signatures[filename];
2261
+ if (!entry) {
2262
+ results.push({
2263
+ filename,
2264
+ verified: false,
2265
+ error: "No signature found in manifest"
2266
+ });
2267
+ hasFailure = true;
2268
+ continue;
2269
+ }
2270
+ const sig = {
2271
+ digest: entry.digest,
2272
+ signature: entry.signature,
2273
+ algorithm: entry.algorithm,
2274
+ signedAt: entry.signedAt
2275
+ };
2276
+ const verified = verifyArtifact(artifactBytes, sig, publicKey);
2277
+ if (!verified) {
2278
+ const actualDigest = computeSha384(artifactBytes);
2279
+ emitSignatureAuditEvent({
2280
+ artifactFilename: filename,
2281
+ expectedDigest: sig.digest,
2282
+ actualDigest,
2283
+ phase: entry.signedBy ?? "unknown",
2284
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2285
+ source: "stackwright_pro_verify_artifact_signatures"
2286
+ });
2287
+ results.push({
2288
+ filename,
2289
+ verified: false,
2290
+ error: `Signature verification failed \u2014 artifact may have been tampered with`,
2291
+ signedBy: entry.signedBy,
2292
+ signedAt: entry.signedAt
2293
+ });
2294
+ hasFailure = true;
2295
+ } else {
2296
+ results.push({
2297
+ filename,
2298
+ verified: true,
2299
+ signedBy: entry.signedBy,
2300
+ signedAt: entry.signedAt
2301
+ });
2302
+ }
2303
+ }
2304
+ for (const manifestFilename of Object.keys(manifest.signatures)) {
2305
+ if (!artifactFiles.includes(manifestFilename)) {
2306
+ results.push({
2307
+ filename: manifestFilename,
2308
+ verified: false,
2309
+ error: "Artifact referenced in manifest but missing from disk"
2310
+ });
2311
+ hasFailure = true;
2312
+ }
2313
+ }
2314
+ const verifiedCount = results.filter((r) => r.verified).length;
2315
+ const failedCount = results.filter((r) => !r.verified).length;
2316
+ return {
2317
+ content: [
2318
+ {
2319
+ type: "text",
2320
+ text: JSON.stringify({
2321
+ totalArtifacts: artifactFiles.length,
2322
+ verifiedCount,
2323
+ failedCount,
2324
+ results,
2325
+ ...hasFailure ? {
2326
+ error: "SIGNATURE VERIFICATION FAILED: One or more artifact signatures are invalid. Do not proceed \u2014 artifacts may have been tampered with."
2327
+ } : {}
2328
+ })
2329
+ }
2330
+ ],
2331
+ isError: hasFailure
2332
+ };
2333
+ }
2334
+ );
2335
+ }
2336
+
2337
+ // src/tools/pipeline.ts
2338
+ import { WorkflowFileSchema, authConfigSchema } from "@stackwright-pro/types";
2339
+ var PHASE_ORDER = [
2340
+ "designer",
2341
+ "theme",
2342
+ "api",
2343
+ "data",
2344
+ "geo",
2345
+ "workflow",
2346
+ "services",
2347
+ "pages",
2348
+ "dashboard",
2349
+ "auth",
2350
+ "polish"
2351
+ ];
2352
+ var PHASE_DEPENDENCIES = {
2353
+ designer: [],
2354
+ theme: ["designer"],
2355
+ api: [],
2356
+ data: ["api"],
2357
+ geo: ["data"],
2358
+ // workflow: no hard deps — uses service: references with Prism mock fallback
2359
+ // when API artifacts aren't available; roles come from user answers
2360
+ workflow: [],
2361
+ // services: needs API endpoints to know what to wire, and optionally
2362
+ // workflow states as trigger sources. Produces OpenAPI specs consumed
2363
+ // by pages and dashboard for typed data wiring.
2364
+ services: ["api", "workflow"],
2365
+ // pages/dashboard: depend on services for typed endpoint wiring (OpenAPI
2366
+ // specs) and on geo so the specialist prompt includes geo-manifest.json
2367
+ // — page/dashboard otters see which page slugs are already claimed and
2368
+ // won't attempt to overwrite them (swp-73c). 'api' is still transitive
2369
+ // through 'data'; auth removed — page-otter has graceful fallback.
2370
+ pages: ["designer", "theme", "data", "services", "geo"],
2371
+ dashboard: ["designer", "theme", "data", "services", "geo"],
2372
+ // auth is the penultimate phase — runs after all content-producing phases
2373
+ // so it can read pages, dashboard, workflow, and geo artifacts for route
2374
+ // protection and RBAC wiring. Skipped upstream phases still satisfy deps
2375
+ // (the foreman marks them executed=true), so auth always runs.
2376
+ auth: ["pages", "dashboard", "workflow", "geo"],
2377
+ // polish is the terminal phase — runs after auth so the landing page and
2378
+ // nav reflect the final set of protected routes and all generated pages.
2379
+ polish: ["auth"]
2380
+ };
2381
+ var PHASE_ARTIFACT = {
2382
+ designer: "design-language.json",
2383
+ theme: "theme-tokens.json",
2384
+ api: "api-config.json",
2385
+ auth: "auth-config.json",
2386
+ data: "data-config.json",
2387
+ pages: "pages-manifest.json",
2388
+ dashboard: "dashboard-manifest.json",
2389
+ workflow: "workflow-config.json",
2390
+ services: "services-config.json",
2391
+ polish: "polish-manifest.json",
2392
+ geo: "geo-manifest.json"
2393
+ };
2394
+ var PHASE_TO_OTTER2 = {
2395
+ designer: "stackwright-pro-designer-otter",
2396
+ theme: "stackwright-pro-theme-otter",
2397
+ api: "stackwright-pro-api-otter",
2398
+ auth: "stackwright-pro-auth-otter",
2399
+ data: "stackwright-pro-data-otter",
2400
+ pages: "stackwright-pro-page-otter",
2401
+ dashboard: "stackwright-pro-dashboard-otter",
2402
+ workflow: "stackwright-pro-workflow-otter",
2403
+ services: "stackwright-services-otter",
2404
+ polish: "stackwright-pro-polish-otter",
2405
+ geo: "stackwright-pro-geo-otter"
2406
+ };
2407
+ function isValidPhase2(phase) {
2408
+ return PHASE_ORDER.includes(phase);
2409
+ }
1677
2410
  function defaultPhaseStatus() {
1678
2411
  return {
1679
2412
  questionsCollected: false,
@@ -1699,11 +2432,11 @@ function createDefaultState() {
1699
2432
  };
1700
2433
  }
1701
2434
  function statePath(cwd) {
1702
- return join3(cwd, ".stackwright", "pipeline-state.json");
2435
+ return join4(cwd, ".stackwright", "pipeline-state.json");
1703
2436
  }
1704
2437
  function readState(cwd) {
1705
2438
  const p = statePath(cwd);
1706
- if (!existsSync3(p)) return createDefaultState();
2439
+ if (!existsSync5(p)) return createDefaultState();
1707
2440
  const raw = JSON.parse(safeReadSync(p));
1708
2441
  if (typeof raw !== "object" || raw === null || raw.version !== "1.0") {
1709
2442
  return createDefaultState();
@@ -1711,26 +2444,26 @@ function readState(cwd) {
1711
2444
  return raw;
1712
2445
  }
1713
2446
  function safeWriteSync(filePath, content) {
1714
- if (existsSync3(filePath)) {
1715
- const stat = lstatSync3(filePath);
2447
+ if (existsSync5(filePath)) {
2448
+ const stat = lstatSync5(filePath);
1716
2449
  if (stat.isSymbolicLink()) {
1717
2450
  throw new Error(`Refusing to write to symlink: ${filePath}`);
1718
2451
  }
1719
2452
  }
1720
- writeFileSync3(filePath, content);
2453
+ writeFileSync4(filePath, content);
1721
2454
  }
1722
2455
  function safeReadSync(filePath) {
1723
- if (existsSync3(filePath)) {
1724
- const stat = lstatSync3(filePath);
2456
+ if (existsSync5(filePath)) {
2457
+ const stat = lstatSync5(filePath);
1725
2458
  if (stat.isSymbolicLink()) {
1726
2459
  throw new Error(`Refusing to read symlink: ${filePath}`);
1727
2460
  }
1728
2461
  }
1729
- return readFileSync3(filePath, "utf-8");
2462
+ return readFileSync4(filePath, "utf-8");
1730
2463
  }
1731
2464
  function writeState(cwd, state) {
1732
- const dir = join3(cwd, ".stackwright");
1733
- mkdirSync2(dir, { recursive: true });
2465
+ const dir = join4(cwd, ".stackwright");
2466
+ mkdirSync4(dir, { recursive: true });
1734
2467
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1735
2468
  safeWriteSync(statePath(cwd), JSON.stringify(state, null, 2) + "\n");
1736
2469
  }
@@ -1751,6 +2484,15 @@ function handleGetPipelineState(_cwd) {
1751
2484
  const cwd = _cwd ?? process.cwd();
1752
2485
  try {
1753
2486
  const state = readState(cwd);
2487
+ const keyPath = join4(cwd, ".stackwright", "pipeline-keys.json");
2488
+ if (!existsSync5(keyPath)) {
2489
+ try {
2490
+ const { fingerprint } = initPipelineKeys(cwd);
2491
+ state["signingKeyFingerprint"] = fingerprint;
2492
+ writeState(cwd, state);
2493
+ } catch {
2494
+ }
2495
+ }
1754
2496
  return { text: JSON.stringify(state), isError: false };
1755
2497
  } catch (err) {
1756
2498
  return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };
@@ -1758,7 +2500,7 @@ function handleGetPipelineState(_cwd) {
1758
2500
  }
1759
2501
  function handleSetPipelineState(input) {
1760
2502
  const cwd = input._cwd ?? process.cwd();
1761
- if (input.phase && !isValidPhase(input.phase)) {
2503
+ if (input.phase && !isValidPhase2(input.phase)) {
1762
2504
  return {
1763
2505
  text: JSON.stringify({
1764
2506
  error: true,
@@ -1777,6 +2519,28 @@ function handleSetPipelineState(input) {
1777
2519
  isError: true
1778
2520
  };
1779
2521
  }
2522
+ if (input.updates && input.updates.length > 0) {
2523
+ for (const update of input.updates) {
2524
+ if (!isValidPhase2(update.phase)) {
2525
+ return {
2526
+ text: JSON.stringify({
2527
+ error: true,
2528
+ message: `Invalid phase in batch update: ${update.phase}. Valid phases are: ${PHASE_ORDER.join(", ")}`
2529
+ }),
2530
+ isError: true
2531
+ };
2532
+ }
2533
+ if (!VALID_FIELDS.includes(update.field)) {
2534
+ return {
2535
+ text: JSON.stringify({
2536
+ error: true,
2537
+ message: `Invalid field in batch update: ${update.field}. Valid fields are: ${VALID_FIELDS.join(", ")}`
2538
+ }),
2539
+ isError: true
2540
+ };
2541
+ }
2542
+ }
2543
+ }
1780
2544
  try {
1781
2545
  const state = readState(cwd);
1782
2546
  if (input.status) {
@@ -1796,34 +2560,78 @@ function handleSetPipelineState(input) {
1796
2560
  }
1797
2561
  state.currentPhase = phase;
1798
2562
  }
2563
+ if (input.updates && input.updates.length > 0) {
2564
+ for (const update of input.updates) {
2565
+ if (!state.phases[update.phase]) {
2566
+ state.phases[update.phase] = defaultPhaseStatus();
2567
+ }
2568
+ const batchPhaseState = state.phases[update.phase];
2569
+ batchPhaseState[update.field] = update.value;
2570
+ state.currentPhase = update.phase;
2571
+ }
2572
+ }
1799
2573
  writeState(cwd, state);
1800
2574
  return { text: JSON.stringify(state), isError: false };
1801
2575
  } catch (err) {
1802
2576
  return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };
1803
2577
  }
1804
2578
  }
1805
- function handleCheckExecutionReady(_cwd) {
2579
+ function handleCheckExecutionReady(_cwd, phase) {
1806
2580
  const cwd = _cwd ?? process.cwd();
2581
+ if (phase) {
2582
+ if (!isValidPhase2(phase)) {
2583
+ return {
2584
+ text: JSON.stringify({
2585
+ error: true,
2586
+ message: `Invalid phase: ${phase}. Valid phases are: ${PHASE_ORDER.join(", ")}`
2587
+ }),
2588
+ isError: true
2589
+ };
2590
+ }
2591
+ const answerFile = join4(cwd, ".stackwright", "answers", `${phase}.json`);
2592
+ if (!existsSync5(answerFile)) {
2593
+ return {
2594
+ text: JSON.stringify({ ready: false, phase, reason: "Answer file not found" }),
2595
+ isError: false
2596
+ };
2597
+ }
2598
+ try {
2599
+ const raw = safeReadSync(answerFile);
2600
+ const parsed = JSON.parse(raw);
2601
+ if (typeof parsed["version"] !== "string" || typeof parsed["phase"] !== "string" || typeof parsed["answers"] !== "object" || parsed["answers"] === null) {
2602
+ return {
2603
+ text: JSON.stringify({ ready: false, phase, reason: "Answer file is malformed" }),
2604
+ isError: false
2605
+ };
2606
+ }
2607
+ return { text: JSON.stringify({ ready: true, phase }), isError: false };
2608
+ } catch {
2609
+ return {
2610
+ text: JSON.stringify({ ready: false, phase, reason: "Answer file could not be parsed" }),
2611
+ isError: false
2612
+ };
2613
+ }
2614
+ }
1807
2615
  try {
1808
- const answersDir = join3(cwd, ".stackwright", "answers");
2616
+ const answersDir = join4(cwd, ".stackwright", "answers");
1809
2617
  const answeredPhases = [];
1810
2618
  const missingPhases = [];
1811
- for (const phase of PHASE_ORDER) {
1812
- const answerFile = join3(answersDir, `${phase}.json`);
1813
- if (existsSync3(answerFile)) {
2619
+ for (const phase2 of PHASE_ORDER) {
2620
+ const answerFile = join4(answersDir, `${phase2}.json`);
2621
+ if (existsSync5(answerFile)) {
1814
2622
  try {
1815
2623
  const raw = safeReadSync(answerFile);
1816
2624
  const parsed = JSON.parse(raw);
1817
2625
  if (typeof parsed["version"] !== "string" || typeof parsed["phase"] !== "string" || typeof parsed["answers"] !== "object" || parsed["answers"] === null) {
1818
- missingPhases.push(phase);
2626
+ missingPhases.push(phase2);
1819
2627
  continue;
1820
2628
  }
1821
- answeredPhases.push(phase);
2629
+ answeredPhases.push(phase2);
1822
2630
  } catch {
1823
- missingPhases.push(phase);
2631
+ missingPhases.push(phase2);
1824
2632
  }
1825
2633
  } else {
1826
- missingPhases.push(phase);
2634
+ missingPhases.push(phase2);
1827
2635
  }
1828
2636
  }
1829
2637
  return {
@@ -1839,18 +2647,74 @@ function handleCheckExecutionReady(_cwd) {
1839
2647
  return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };
1840
2648
  }
1841
2649
  }
2650
+ function handleGetReadyPhases(_cwd) {
2651
+ const cwd = _cwd ?? process.cwd();
2652
+ try {
2653
+ const state = readState(cwd);
2654
+ const completed = [];
2655
+ const ready = [];
2656
+ const blocked = [];
2657
+ for (const phase of PHASE_ORDER) {
2658
+ const ps = state.phases[phase];
2659
+ if (ps?.executed) {
2660
+ completed.push(phase);
2661
+ continue;
2662
+ }
2663
+ const deps = PHASE_DEPENDENCIES[phase];
2664
+ const unmetDeps = deps.filter((dep) => !state.phases[dep]?.executed);
2665
+ if (unmetDeps.length === 0) {
2666
+ ready.push(phase);
2667
+ } else {
2668
+ blocked.push(phase);
2669
+ }
2670
+ }
2671
+ return {
2672
+ text: JSON.stringify({
2673
+ readyPhases: ready,
2674
+ completedPhases: completed,
2675
+ blockedPhases: blocked,
2676
+ waveSize: ready.length,
2677
+ allComplete: ready.length === 0 && blocked.length === 0
2678
+ }),
2679
+ isError: false
2680
+ };
2681
+ } catch (err) {
2682
+ const message = err instanceof Error ? err.message : String(err);
2683
+ return { text: JSON.stringify({ error: true, message }), isError: true };
2684
+ }
2685
+ }
1842
2686
  function handleListArtifacts(_cwd) {
1843
2687
  const cwd = _cwd ?? process.cwd();
1844
2688
  try {
1845
- const artifactsDir = join3(cwd, ".stackwright", "artifacts");
2689
+ const artifactsDir = join4(cwd, ".stackwright", "artifacts");
2690
+ let manifest = null;
2691
+ try {
2692
+ manifest = loadSignatureManifest(cwd);
2693
+ } catch {
2694
+ }
1846
2695
  const artifacts = [];
1847
2696
  let completedCount = 0;
1848
2697
  for (const phase of PHASE_ORDER) {
1849
2698
  const expectedFile = PHASE_ARTIFACT[phase];
1850
- const fullPath = join3(artifactsDir, expectedFile);
1851
- const exists = existsSync3(fullPath);
2699
+ const fullPath = join4(artifactsDir, expectedFile);
2700
+ const exists = existsSync5(fullPath);
1852
2701
  if (exists) completedCount++;
1853
- artifacts.push({ phase, expectedFile, exists, path: fullPath });
2702
+ let signed = false;
2703
+ let signatureValid = null;
2704
+ if (exists && manifest) {
2705
+ const entry = manifest.signatures[expectedFile];
2706
+ if (entry) {
2707
+ signed = true;
2708
+ try {
2709
+ const rawBytes = Buffer.from(safeReadSync(fullPath), "utf-8");
2710
+ const { publicKey } = loadPipelineKeys(cwd);
2711
+ signatureValid = verifyArtifact(rawBytes, entry, publicKey);
2712
+ } catch {
2713
+ signatureValid = null;
2714
+ }
2715
+ }
2716
+ }
2717
+ artifacts.push({ phase, expectedFile, exists, path: fullPath, signed, signatureValid });
1854
2718
  }
1855
2719
  return {
1856
2720
  text: JSON.stringify({ artifacts, completedCount, totalCount: PHASE_ORDER.length }),
@@ -1863,7 +2727,7 @@ function handleListArtifacts(_cwd) {
1863
2727
  function handleWritePhaseQuestions(input) {
1864
2728
  const cwd = input._cwd ?? process.cwd();
1865
2729
  const { phase, responseText } = input;
1866
- if (!isValidPhase(phase)) {
2730
+ if (!isValidPhase2(phase)) {
1867
2731
  return {
1868
2732
  text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),
1869
2733
  isError: true
@@ -1886,9 +2750,9 @@ function handleWritePhaseQuestions(input) {
1886
2750
  }
1887
2751
  } catch {
1888
2752
  }
1889
- const questionsDir = join3(cwd, ".stackwright", "questions");
1890
- mkdirSync2(questionsDir, { recursive: true });
1891
- const filePath = join3(questionsDir, `${phase}.json`);
2753
+ const questionsDir = join4(cwd, ".stackwright", "questions");
2754
+ mkdirSync4(questionsDir, { recursive: true });
2755
+ const filePath = join4(questionsDir, `${phase}.json`);
1892
2756
  const payload = {
1893
2757
  version: "1.0",
1894
2758
  phase,
@@ -1921,43 +2785,88 @@ function handleWritePhaseQuestions(input) {
1921
2785
  function handleBuildSpecialistPrompt(input) {
1922
2786
  const cwd = input._cwd ?? process.cwd();
1923
2787
  const { phase } = input;
1924
- if (!isValidPhase(phase)) {
2788
+ if (!isValidPhase2(phase)) {
1925
2789
  return {
1926
2790
  text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),
1927
2791
  isError: true
1928
2792
  };
1929
2793
  }
1930
2794
  try {
1931
- const answersPath = join3(cwd, ".stackwright", "answers", `${phase}.json`);
2795
+ const answersPath = join4(cwd, ".stackwright", "answers", `${phase}.json`);
1932
2796
  let answers = {};
1933
- if (existsSync3(answersPath)) {
2797
+ if (existsSync5(answersPath)) {
1934
2798
  answers = JSON.parse(safeReadSync(answersPath));
1935
2799
  }
2800
+ let buildContextText = "";
2801
+ const buildContextPath = join4(cwd, ".stackwright", "build-context.json");
2802
+ if (existsSync5(buildContextPath)) {
2803
+ try {
2804
+ const bcRaw = JSON.parse(safeReadSync(buildContextPath));
2805
+ if (typeof bcRaw.buildContext === "string" && bcRaw.buildContext.trim().length > 0) {
2806
+ buildContextText = bcRaw.buildContext.trim();
2807
+ }
2808
+ } catch {
2809
+ }
2810
+ }
1936
2811
  const deps = PHASE_DEPENDENCIES[phase];
1937
2812
  const artifactSections = [];
1938
2813
  const missingDependencies = [];
1939
2814
  for (const dep of deps) {
1940
2815
  const artifactFile = PHASE_ARTIFACT[dep];
1941
- const artifactPath = join3(cwd, ".stackwright", "artifacts", artifactFile);
1942
- if (existsSync3(artifactPath)) {
1943
- const content = JSON.parse(safeReadSync(artifactPath));
2816
+ const artifactPath = join4(cwd, ".stackwright", "artifacts", artifactFile);
2817
+ if (existsSync5(artifactPath)) {
2818
+ const rawContent = safeReadSync(artifactPath);
2819
+ const rawBytes = Buffer.from(rawContent, "utf-8");
2820
+ const content = JSON.parse(rawContent);
2821
+ let signatureVerified = false;
2822
+ let signatureAvailable = false;
2823
+ try {
2824
+ const sig = getArtifactSignature(cwd, artifactFile);
2825
+ if (sig) {
2826
+ signatureAvailable = true;
2827
+ const { publicKey } = loadPipelineKeys(cwd);
2828
+ signatureVerified = verifyArtifact(rawBytes, sig, publicKey);
2829
+ if (!signatureVerified) {
2830
+ const actualDigest = createHash3("sha384").update(rawBytes).digest("hex");
2831
+ emitSignatureAuditEvent({
2832
+ artifactFilename: artifactFile,
2833
+ expectedDigest: sig.digest,
2834
+ actualDigest,
2835
+ phase: dep,
2836
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2837
+ source: "stackwright_pro_build_specialist_prompt"
2838
+ });
2839
+ missingDependencies.push(dep);
2840
+ artifactSections.push(
2841
+ `[${artifactFile}]:
2842
+ (integrity check failed: ECDSA-P384 signature verification failed \u2014 artifact may have been tampered with)`
2843
+ );
2844
+ continue;
2845
+ }
2846
+ }
2847
+ } catch {
2848
+ }
1944
2849
  const expectedOtter = PHASE_TO_OTTER2[dep];
1945
2850
  const artifactOtter = content["generatedBy"];
2851
+ const normalizedOtter = artifactOtter?.replace(/-[a-f0-9]{6}$/, "");
1946
2852
  if (!artifactOtter) {
1947
2853
  missingDependencies.push(dep);
1948
2854
  artifactSections.push(
1949
2855
  `[${artifactFile}]:
1950
2856
  (integrity check failed: missing generatedBy field)`
1951
2857
  );
1952
- } else if (artifactOtter !== expectedOtter) {
2858
+ } else if (normalizedOtter !== expectedOtter) {
1953
2859
  missingDependencies.push(dep);
1954
2860
  artifactSections.push(
1955
2861
  `[${artifactFile}]:
1956
2862
  (integrity check failed: artifact claims generatedBy="${artifactOtter}" but expected="${expectedOtter}")`
1957
2863
  );
1958
2864
  } else {
1959
- artifactSections.push(`[${artifactFile}]:
1960
- ${JSON.stringify(content, null, 2)}`);
2865
+ const sigStatus = signatureAvailable ? signatureVerified ? " [signature verified]" : " [signature check skipped]" : " [unsigned]";
2866
+ artifactSections.push(
2867
+ `[${artifactFile}]${sigStatus}:
2868
+ ${JSON.stringify(content, null, 2)}`
2869
+ );
1961
2870
  }
1962
2871
  } else {
1963
2872
  missingDependencies.push(dep);
@@ -1965,10 +2874,29 @@ ${JSON.stringify(content, null, 2)}`);
1965
2874
  (not yet available)`);
1966
2875
  }
1967
2876
  }
1968
- const parts = ["ANSWERS:", JSON.stringify(answers, null, 2)];
2877
+ const parts = [];
2878
+ if (buildContextText) {
2879
+ parts.push("BUILD_CONTEXT:", buildContextText, "");
2880
+ }
2881
+ if (phase === "auth") {
2882
+ const initContextPath = join4(cwd, ".stackwright", "init-context.json");
2883
+ if (existsSync5(initContextPath)) {
2884
+ try {
2885
+ const initContext = JSON.parse(safeReadSync(initContextPath));
2886
+ if (initContext.devOnly === true || initContext.nonInteractive === true) {
2887
+ answers["devOnly"] = true;
2888
+ }
2889
+ } catch {
2890
+ }
2891
+ }
2892
+ }
2893
+ parts.push("ANSWERS:", JSON.stringify(answers, null, 2));
1969
2894
  if (artifactSections.length > 0) {
1970
2895
  parts.push("", "UPSTREAM ARTIFACTS:", "", ...artifactSections);
1971
2896
  }
2897
+ const artifactSchema = PHASE_ARTIFACT_SCHEMA[phase];
2898
+ parts.push("", "REQUIRED_ARTIFACT_SCHEMA:");
2899
+ parts.push(artifactSchema);
1972
2900
  parts.push("", "Execute using these answers and the upstream artifacts provided.");
1973
2901
  const prompt = parts.join("\n");
1974
2902
  const dependenciesSatisfied = missingDependencies.length === 0;
@@ -2003,45 +2931,319 @@ var OFF_SCRIPT_PATTERNS = [
2003
2931
  var PHASE_REQUIRED_KEYS = {
2004
2932
  designer: ["designLanguage", "themeTokenSeeds"],
2005
2933
  theme: ["tokens"],
2006
- api: ["entities"],
2934
+ api: ["entities", "version", "generatedBy"],
2007
2935
  auth: ["version", "generatedBy"],
2008
- data: ["version", "generatedBy"],
2936
+ data: ["version", "generatedBy", "strategy", "collections"],
2009
2937
  pages: ["version", "generatedBy"],
2010
2938
  dashboard: ["version", "generatedBy"],
2011
- workflow: ["version", "generatedBy"]
2939
+ workflow: ["version", "generatedBy"],
2940
+ services: ["version", "generatedBy", "flows"],
2941
+ polish: ["version", "generatedBy"],
2942
+ geo: ["version", "generatedBy", "geoCollections"]
2012
2943
  };
2013
- function handleValidateArtifact(input) {
2014
- const cwd = input._cwd ?? process.cwd();
2015
- const { phase, responseText } = input;
2016
- if (!isValidPhase(phase)) {
2017
- return {
2018
- text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),
2019
- isError: true
2020
- };
2021
- }
2022
- for (const { pattern, label } of OFF_SCRIPT_PATTERNS) {
2023
- if (pattern.test(responseText)) {
2024
- const result = {
2025
- valid: false,
2026
- phase,
2027
- violation: "off-script",
2028
- retryPrompt: `You returned code output (detected: ${label}). Return ONLY a JSON artifact \u2014 no code, no files.`
2029
- };
2030
- return { text: JSON.stringify(result), isError: false };
2031
- }
2032
- }
2033
- let artifact;
2034
- try {
2035
- artifact = extractJsonFromResponse(responseText);
2036
- } catch {
2037
- const result = {
2038
- valid: false,
2039
- phase,
2040
- violation: "invalid-json",
2041
- retryPrompt: "Your response did not contain valid JSON. Return a single JSON object with no surrounding text."
2042
- };
2043
- return { text: JSON.stringify(result), isError: false };
2044
- }
2944
+ var PHASE_ARTIFACT_SCHEMA = {
2945
+ designer: JSON.stringify(
2946
+ {
2947
+ version: "1.0",
2948
+ generatedBy: "stackwright-pro-designer-otter",
2949
+ application: {
2950
+ type: "<operational|data-explorer|admin|logistics|general>",
2951
+ environment: "<workstation|field|control-room|mixed>",
2952
+ density: "<compact|balanced|spacious>",
2953
+ accessibility: "<wcag-aa|section-508|none>",
2954
+ colorScheme: "<light|dark|both>"
2955
+ },
2956
+ designLanguage: {
2957
+ rationale: "<design rationale>",
2958
+ spacingScale: { base: 8, scale: [0, 4, 8, 16, 24, 32, 48, 64] },
2959
+ colorSemantics: { primary: "#1a365d", accent: "#e53e3e" },
2960
+ typography: {
2961
+ dataFont: "Inter",
2962
+ headingFont: "Inter",
2963
+ monoFont: "monospace",
2964
+ dataSizePx: 12,
2965
+ bodySizePx: 14
2966
+ },
2967
+ contrastRatio: "4.5",
2968
+ borderRadius: "4",
2969
+ shadowElevation: "standard"
2970
+ },
2971
+ themeTokenSeeds: {
2972
+ light: {
2973
+ background: "#ffffff",
2974
+ foreground: "#1a1a1a",
2975
+ primary: "#1a365d",
2976
+ surface: "#f7f7f7",
2977
+ border: "#e2e8f0"
2978
+ },
2979
+ dark: {
2980
+ background: "#1a1a1a",
2981
+ foreground: "#ffffff",
2982
+ primary: "#90cdf4",
2983
+ surface: "#2d2d2d",
2984
+ border: "#4a5568"
2985
+ }
2986
+ },
2987
+ conformsTo: null,
2988
+ operationalNotes: []
2989
+ },
2990
+ null,
2991
+ 2
2992
+ ),
2993
+ theme: JSON.stringify(
2994
+ {
2995
+ version: "1.0",
2996
+ generatedBy: "stackwright-pro-theme-otter",
2997
+ componentLibrary: "shadcn",
2998
+ colorScheme: "<light|dark|both>",
2999
+ tokens: {
3000
+ colors: { "primary-500": "#1a365d", background: "#ffffff" },
3001
+ spacing: { "spacing-1": "8px", "spacing-2": "16px" },
3002
+ typography: { "font-data": "Inter", "text-sm": "12px" },
3003
+ shape: { "radius-sm": "4px", "radius-md": "8px" },
3004
+ shadows: { "shadow-sm": "0 1px 2px rgba(0,0,0,0.08)" }
3005
+ },
3006
+ cssVariables: {
3007
+ "--background": "0 0% 100%",
3008
+ "--foreground": "222.2 84% 4.9%",
3009
+ "--primary": "222.2 47.4% 11.2%",
3010
+ "--primary-foreground": "210 40% 98%",
3011
+ "--surface": "210 40% 98%",
3012
+ "--border": "214.3 31.8% 91.4%"
3013
+ },
3014
+ dark: { "--background": "222.2 84% 4.9%", "--foreground": "210 40% 98%" }
3015
+ },
3016
+ null,
3017
+ 2
3018
+ ),
3019
+ api: JSON.stringify(
3020
+ {
3021
+ version: "1.0",
3022
+ generatedBy: "stackwright-pro-api-otter",
3023
+ entities: [
3024
+ {
3025
+ name: "Shipment",
3026
+ endpoint: "/shipments",
3027
+ method: "GET",
3028
+ revalidate: 60,
3029
+ mutationType: null
3030
+ }
3031
+ ],
3032
+ skipped: [
3033
+ {
3034
+ spec: "ais-message-models.yaml",
3035
+ format: "asyncapi",
3036
+ reason: "AsyncAPI spec \u2014 WebSocket/event integration required (no REST endpoints)"
3037
+ }
3038
+ ],
3039
+ auth: { type: "bearer", header: "Authorization", envVar: "API_TOKEN" },
3040
+ baseUrl: "https://api.example.mil/v2",
3041
+ specPath: "./specs/api.yaml"
3042
+ },
3043
+ null,
3044
+ 2
3045
+ ),
3046
+ data: JSON.stringify(
3047
+ {
3048
+ version: "1.0",
3049
+ generatedBy: "stackwright-pro-data-otter",
3050
+ strategy: "<pulse-fast|isr-fast|isr-standard|isr-slow>",
3051
+ pulseMode: false,
3052
+ collections: [{ name: "equipment", revalidate: 60, pulse: false }],
3053
+ endpoints: { included: ["/equipment/**"], excluded: ["/admin/**"] },
3054
+ requiredPackages: { dependencies: {}, devPackages: {} }
3055
+ },
3056
+ null,
3057
+ 2
3058
+ ),
3059
+ geo: JSON.stringify(
3060
+ {
3061
+ version: "1.0",
3062
+ generatedBy: "stackwright-pro-geo-otter",
3063
+ geoCollections: [
3064
+ {
3065
+ collection: "vessels",
3066
+ latField: "latitude",
3067
+ lngField: "longitude",
3068
+ labelField: "vesselName",
3069
+ colorField: "navigationStatus"
3070
+ }
3071
+ ],
3072
+ pages: [
3073
+ {
3074
+ slug: "fleet-tracker",
3075
+ type: "<tracker|zone|route|combined>",
3076
+ collections: ["vessels"],
3077
+ hasLayers: false
3078
+ }
3079
+ ]
3080
+ },
3081
+ null,
3082
+ 2
3083
+ ),
3084
+ workflow: JSON.stringify(
3085
+ {
3086
+ version: "1.0",
3087
+ generatedBy: "stackwright-pro-workflow-otter",
3088
+ workflowConfig: {
3089
+ id: "procurement-approval",
3090
+ route: "/procurement",
3091
+ files: ["workflows/procurement-approval.yml"],
3092
+ serviceDependencies: ["service:workflow-state"],
3093
+ warnings: []
3094
+ }
3095
+ },
3096
+ null,
3097
+ 2
3098
+ ),
3099
+ services: JSON.stringify(
3100
+ {
3101
+ version: "1.0",
3102
+ generatedBy: "stackwright-services-otter",
3103
+ flows: [
3104
+ {
3105
+ name: "at-risk-patients",
3106
+ trigger: "<http|event|schedule|queue>",
3107
+ steps: 3,
3108
+ outputSpec: "at-risk-patients.openapi.json"
3109
+ }
3110
+ ],
3111
+ workflows: [
3112
+ {
3113
+ name: "evacuation-coordination",
3114
+ states: 4,
3115
+ transitions: 5
3116
+ }
3117
+ ],
3118
+ openApiSpecs: ["at-risk-patients.openapi.json"],
3119
+ capabilitiesUsed: ["service.call", "collection.join", "collection.filter"]
3120
+ },
3121
+ null,
3122
+ 2
3123
+ ),
3124
+ pages: JSON.stringify(
3125
+ {
3126
+ version: "1.0",
3127
+ generatedBy: "stackwright-pro-page-otter",
3128
+ pages: [
3129
+ {
3130
+ slug: "catalog",
3131
+ type: "collection_listing",
3132
+ collection: "products",
3133
+ themeApplied: true,
3134
+ authRequired: false
3135
+ },
3136
+ {
3137
+ slug: "admin",
3138
+ type: "protected",
3139
+ collection: null,
3140
+ themeApplied: true,
3141
+ authRequired: true
3142
+ }
3143
+ ]
3144
+ },
3145
+ null,
3146
+ 2
3147
+ ),
3148
+ dashboard: JSON.stringify(
3149
+ {
3150
+ version: "1.0",
3151
+ generatedBy: "stackwright-pro-dashboard-otter",
3152
+ pages: [
3153
+ {
3154
+ slug: "dashboard",
3155
+ layout: "<grid|table|mixed>",
3156
+ collections: ["equipment", "supplies"],
3157
+ mode: "<ISR|Pulse>"
3158
+ }
3159
+ ]
3160
+ },
3161
+ null,
3162
+ 2
3163
+ ),
3164
+ // type: 'pki' = CAC/DoD certificate auth | 'oidc' = enterprise SSO
3165
+ // For dev-only mock auth: use type: 'oidc' with devOnly: true (Zod strips devOnly — it's a convention only)
3166
+ auth: JSON.stringify(
3167
+ {
3168
+ version: "1.0",
3169
+ generatedBy: "stackwright-pro-auth-otter",
3170
+ authConfig: {
3171
+ type: "<pki|oidc>",
3172
+ // OIDC-only fields (omit for pki):
3173
+ provider: "<azure_ad|okta|cognito|auth0|authentik|keycloak|custom>",
3174
+ discoveryUrl: "<IdP OIDC discovery URL>",
3175
+ clientId: "<OIDC client ID>",
3176
+ clientSecret: "<OIDC client secret>",
3177
+ rbacRoles: ["ADMIN", "ANALYST"],
3178
+ rbacDefaultRole: "ANALYST",
3179
+ protectedRoutes: ["/dashboard/:path*", "/procurement/:path*"],
3180
+ auditEnabled: true,
3181
+ auditRetentionDays: 90
3182
+ },
3183
+ // devScripts is OPTIONAL — only present when devOnly: true was used
3184
+ // Polish otter and foreman MUST check devScripts.written before referencing scripts
3185
+ devScripts: {
3186
+ written: true,
3187
+ scripts: { "dev:admin": "MOCK_USER=admin next dev" }
3188
+ }
3189
+ },
3190
+ null,
3191
+ 2
3192
+ ),
3193
+ polish: JSON.stringify(
3194
+ {
3195
+ version: "1.0",
3196
+ generatedBy: "stackwright-pro-polish-otter",
3197
+ landingPage: { slug: "", rewritten: true },
3198
+ navigation: { itemCount: 5, items: ["/dashboard", "/equipment", "/workflows"] },
3199
+ gettingStarted: "<rewritten|redirected|not-found>",
3200
+ scaffoldCleanup: {
3201
+ deleted: ["content/posts/getting-started.yaml"],
3202
+ rewritten: ["app/not-found.tsx"],
3203
+ skipped: []
3204
+ }
3205
+ },
3206
+ null,
3207
+ 2
3208
+ )
3209
+ };
3210
+ function handleValidateArtifact(input) {
3211
+ const cwd = input._cwd ?? process.cwd();
3212
+ const { phase, responseText, artifact: directArtifact } = input;
3213
+ if (!isValidPhase2(phase)) {
3214
+ return {
3215
+ text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),
3216
+ isError: true
3217
+ };
3218
+ }
3219
+ let artifact;
3220
+ if (directArtifact) {
3221
+ artifact = directArtifact;
3222
+ } else {
3223
+ const text = responseText ?? "";
3224
+ for (const { pattern, label } of OFF_SCRIPT_PATTERNS) {
3225
+ if (pattern.test(text)) {
3226
+ const result = {
3227
+ valid: false,
3228
+ phase,
3229
+ violation: "off-script",
3230
+ retryPrompt: `You returned code output (detected: ${label}). Return ONLY a JSON artifact \u2014 no code, no files.`
3231
+ };
3232
+ return { text: JSON.stringify(result), isError: false };
3233
+ }
3234
+ }
3235
+ try {
3236
+ artifact = extractJsonFromResponse(text);
3237
+ } catch {
3238
+ const result = {
3239
+ valid: false,
3240
+ phase,
3241
+ violation: "invalid-json",
3242
+ retryPrompt: "Your response did not contain valid JSON. Return a single JSON object with no surrounding text."
3243
+ };
3244
+ return { text: JSON.stringify(result), isError: false };
3245
+ }
3246
+ }
2045
3247
  if (!artifact.version || !artifact.generatedBy) {
2046
3248
  const result = {
2047
3249
  valid: false,
@@ -2062,12 +3264,58 @@ function handleValidateArtifact(input) {
2062
3264
  };
2063
3265
  return { text: JSON.stringify(result), isError: false };
2064
3266
  }
3267
+ const PHASE_ZOD_VALIDATORS = {
3268
+ workflow: (artifact2) => {
3269
+ const workflowConfig = artifact2["workflowConfig"];
3270
+ if (!workflowConfig) return { success: true };
3271
+ const result = WorkflowFileSchema.safeParse(workflowConfig);
3272
+ if (!result.success) {
3273
+ const issues = result.error.issues.slice(0, 3).map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
3274
+ return { success: false, error: { message: issues } };
3275
+ }
3276
+ return { success: true };
3277
+ },
3278
+ auth: (artifact2) => {
3279
+ const authConfig = artifact2["authConfig"];
3280
+ if (!authConfig) return { success: true };
3281
+ const result = authConfigSchema.safeParse(authConfig);
3282
+ if (!result.success) {
3283
+ const issues = result.error.issues.slice(0, 3).map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
3284
+ return { success: false, error: { message: issues } };
3285
+ }
3286
+ return { success: true };
3287
+ }
3288
+ };
3289
+ const zodValidator = PHASE_ZOD_VALIDATORS[phase];
3290
+ if (zodValidator) {
3291
+ const zodResult = zodValidator(artifact);
3292
+ if (!zodResult.success) {
3293
+ const result = {
3294
+ valid: false,
3295
+ phase,
3296
+ violation: "schema-mismatch",
3297
+ retryPrompt: `Your artifact failed schema validation: ${zodResult.error?.message}. Fix these fields and return the corrected JSON artifact.`
3298
+ };
3299
+ return { text: JSON.stringify(result), isError: false };
3300
+ }
3301
+ }
2065
3302
  try {
2066
- const artifactsDir = join3(cwd, ".stackwright", "artifacts");
2067
- mkdirSync2(artifactsDir, { recursive: true });
3303
+ const artifactsDir = join4(cwd, ".stackwright", "artifacts");
3304
+ mkdirSync4(artifactsDir, { recursive: true });
2068
3305
  const artifactFile = PHASE_ARTIFACT[phase];
2069
- const artifactPath = join3(artifactsDir, artifactFile);
2070
- safeWriteSync(artifactPath, JSON.stringify(artifact, null, 2) + "\n");
3306
+ const artifactPath = join4(artifactsDir, artifactFile);
3307
+ const serialized = JSON.stringify(artifact, null, 2) + "\n";
3308
+ const artifactBytes = Buffer.from(serialized, "utf-8");
3309
+ safeWriteSync(artifactPath, serialized);
3310
+ let signed = false;
3311
+ try {
3312
+ const { privateKey } = loadPipelineKeys(cwd);
3313
+ const sig = signArtifact(artifactBytes, privateKey);
3314
+ const signerOtter = PHASE_TO_OTTER2[phase];
3315
+ saveArtifactSignature(cwd, artifactFile, sig, signerOtter);
3316
+ signed = true;
3317
+ } catch {
3318
+ }
2071
3319
  const state = readState(cwd);
2072
3320
  if (!state.phases[phase]) state.phases[phase] = defaultPhaseStatus();
2073
3321
  const ps = state.phases[phase];
@@ -2078,7 +3326,7 @@ function handleValidateArtifact(input) {
2078
3326
  valid: true,
2079
3327
  phase,
2080
3328
  artifactPath,
2081
- summary: `Wrote ${artifactFile} (keys: ${topKeys}${Object.keys(artifact).length > 5 ? ", ..." : ""})`
3329
+ summary: `Wrote ${artifactFile} (keys: ${topKeys}${Object.keys(artifact).length > 5 ? ", ..." : ""})${signed ? " [signed]" : ""}`
2082
3330
  };
2083
3331
  return { text: JSON.stringify(result), isError: false };
2084
3332
  } catch (err) {
@@ -2102,11 +3350,24 @@ function registerPipelineTools(server2) {
2102
3350
  "stackwright_pro_set_pipeline_state",
2103
3351
  `Atomic read\u2192modify\u2192write pipeline state. ${DESC}`,
2104
3352
  {
2105
- phase: z9.string().optional().describe('Phase to update, e.g. "designer"'),
2106
- field: z9.enum(["questionsCollected", "answered", "executed", "artifactWritten"]).optional().describe("Boolean field to set"),
2107
- value: z9.boolean().optional().describe("Value for the field"),
2108
- status: z9.enum(["setup", "questions", "execution", "done"]).optional().describe("Top-level status override"),
2109
- incrementRetry: z9.boolean().optional().describe("Bump retryCount by 1")
3353
+ phase: z11.string().optional().describe('Phase to update, e.g. "designer"'),
3354
+ field: z11.enum(["questionsCollected", "answered", "executed", "artifactWritten"]).optional().describe("Boolean field to set"),
3355
+ value: boolCoerce(z11.boolean().optional()).describe(
3356
+ 'Value for the field \u2014 must be a JSON boolean (true/false), NOT the string "true"/"false"'
3357
+ ),
3358
+ status: z11.enum(["setup", "questions", "execution", "done"]).optional().describe("Top-level status override"),
3359
+ incrementRetry: boolCoerce(z11.boolean().optional()).describe(
3360
+ "Bump retryCount by 1 \u2014 must be a JSON boolean"
3361
+ ),
3362
+ updates: jsonCoerce(
3363
+ z11.array(
3364
+ z11.object({
3365
+ phase: z11.string(),
3366
+ field: z11.enum(["questionsCollected", "answered", "executed", "artifactWritten"]),
3367
+ value: z11.boolean()
3368
+ })
3369
+ ).optional()
3370
+ ).describe("Batch updates \u2014 apply multiple field changes in one atomic read\u2192modify\u2192write")
2110
3371
  },
2111
3372
  async (args) => res(
2112
3373
  handleSetPipelineState({
@@ -2114,15 +3375,24 @@ function registerPipelineTools(server2) {
2114
3375
  ...args.field != null ? { field: args.field } : {},
2115
3376
  ...args.value != null ? { value: args.value } : {},
2116
3377
  ...args.status != null ? { status: args.status } : {},
2117
- ...args.incrementRetry != null ? { incrementRetry: args.incrementRetry } : {}
3378
+ ...args.incrementRetry != null ? { incrementRetry: args.incrementRetry } : {},
3379
+ ...args.updates != null ? { updates: args.updates } : {}
2118
3380
  })
2119
3381
  )
2120
3382
  );
2121
3383
  server2.tool(
2122
3384
  "stackwright_pro_check_execution_ready",
2123
- `Check all phases have answer files in .stackwright/answers/. ${DESC}`,
3385
+ `Check all phases have answer files in .stackwright/answers/. If phase is provided, check only that phase. ${DESC}`,
3386
+ {
3387
+ phase: z11.string().optional().describe("If provided, check only this phase's readiness. Omit to check all phases.")
3388
+ },
3389
+ async ({ phase }) => res(handleCheckExecutionReady(void 0, phase))
3390
+ );
3391
+ server2.tool(
3392
+ "stackwright_pro_get_ready_phases",
3393
+ `Return phases whose dependencies are all satisfied (executed=true). Use to determine which phases can run next \u2014 enables wave-based execution. ${DESC}`,
2124
3394
  {},
2125
- async () => res(handleCheckExecutionReady())
3395
+ async () => res(handleGetReadyPhases())
2126
3396
  );
2127
3397
  server2.tool(
2128
3398
  "stackwright_pro_list_artifacts",
@@ -2132,40 +3402,91 @@ function registerPipelineTools(server2) {
2132
3402
  );
2133
3403
  server2.tool(
2134
3404
  "stackwright_pro_write_phase_questions",
2135
- `Parse otter question-collection response \u2192 .stackwright/questions/{phase}.json. ${DESC}`,
3405
+ `Parse otter question-collection response \u2192 .stackwright/questions/{phase}.json. Specialists may also call this directly with a parsed questions array. ${DESC}`,
2136
3406
  {
2137
- phase: z9.string().describe('Phase name, e.g. "designer"'),
2138
- responseText: z9.string().describe("Raw LLM response from QUESTION_COLLECTION_MODE")
3407
+ phase: z11.string().optional().describe('Phase name, e.g. "designer" (required for direct write)'),
3408
+ responseText: z11.string().optional().describe("Raw LLM response from QUESTION_COLLECTION_MODE"),
3409
+ questions: jsonCoerce(z11.array(z11.any()).optional()).describe(
3410
+ "Questions array for direct specialist write"
3411
+ )
2139
3412
  },
2140
- async ({ phase, responseText }) => res(handleWritePhaseQuestions({ phase, responseText }))
3413
+ async ({ phase, responseText, questions }) => {
3414
+ if (phase && questions && Array.isArray(questions)) {
3415
+ if (!SAFE_PHASE.test(phase)) {
3416
+ return {
3417
+ content: [
3418
+ { type: "text", text: JSON.stringify({ error: `Invalid phase name` }) }
3419
+ ],
3420
+ isError: true
3421
+ };
3422
+ }
3423
+ const questionsDir = join4(process.cwd(), ".stackwright", "questions");
3424
+ mkdirSync4(questionsDir, { recursive: true });
3425
+ const outPath = join4(questionsDir, `${phase}.json`);
3426
+ writeFileSync4(
3427
+ outPath,
3428
+ JSON.stringify({ phase, questions, writtenAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)
3429
+ );
3430
+ return {
3431
+ content: [
3432
+ {
3433
+ type: "text",
3434
+ text: JSON.stringify({
3435
+ written: true,
3436
+ phase,
3437
+ count: questions.length
3438
+ })
3439
+ }
3440
+ ]
3441
+ };
3442
+ }
3443
+ return res(
3444
+ handleWritePhaseQuestions({ phase: phase ?? "", responseText: responseText ?? "" })
3445
+ );
3446
+ }
2141
3447
  );
2142
3448
  server2.tool(
2143
3449
  "stackwright_pro_build_specialist_prompt",
2144
3450
  `Assemble execution prompt from answers + upstream artifacts. Foreman passes verbatim. ${DESC}`,
2145
- { phase: z9.string().describe('Phase to build prompt for, e.g. "pages"') },
3451
+ { phase: z11.string().describe('Phase to build prompt for, e.g. "pages"') },
2146
3452
  async ({ phase }) => res(handleBuildSpecialistPrompt({ phase }))
2147
3453
  );
2148
3454
  server2.tool(
2149
3455
  "stackwright_pro_validate_artifact",
2150
- `Validate specialist response + write artifact to .stackwright/artifacts/. Returns retryPrompt on failure. ${DESC}`,
3456
+ `Validate and write artifact to .stackwright/artifacts/. Returns retryPrompt on failure. ${DESC}`,
2151
3457
  {
2152
- phase: z9.string().describe('Phase that produced this artifact, e.g. "designer"'),
2153
- responseText: z9.string().describe("Raw response text from the specialist otter")
3458
+ phase: z11.string().describe('Phase that produced this artifact, e.g. "designer"'),
3459
+ responseText: z11.string().optional().describe("Raw response text from the specialist otter (Foreman-mediated path, legacy)"),
3460
+ artifact: jsonCoerce(z11.record(z11.string(), z11.unknown()).optional()).describe(
3461
+ "Artifact object to validate and write directly (specialist direct path \u2014 skips off-script detection and JSON parsing)"
3462
+ )
2154
3463
  },
2155
- async ({ phase, responseText }) => res(handleValidateArtifact({ phase, responseText }))
3464
+ async ({ phase, responseText, artifact }) => {
3465
+ if (artifact) {
3466
+ return res(
3467
+ handleValidateArtifact({ phase, artifact })
3468
+ );
3469
+ }
3470
+ return res(handleValidateArtifact({ phase, responseText: responseText ?? "" }));
3471
+ }
2156
3472
  );
2157
3473
  }
2158
3474
 
2159
3475
  // src/tools/safe-write.ts
2160
- import { z as z10 } from "zod";
2161
- import { writeFileSync as writeFileSync4, existsSync as existsSync4, mkdirSync as mkdirSync3, lstatSync as lstatSync4 } from "fs";
2162
- import { normalize, isAbsolute, dirname, join as join4 } from "path";
3476
+ import { z as z12 } from "zod";
3477
+ import { writeFileSync as writeFileSync5, readFileSync as readFileSync5, existsSync as existsSync6, mkdirSync as mkdirSync5, lstatSync as lstatSync6 } from "fs";
3478
+ import { normalize, isAbsolute, dirname, join as join5 } from "path";
2163
3479
  var OTTER_WRITE_ALLOWLISTS = {
2164
3480
  "stackwright-pro-designer-otter": [
2165
3481
  { prefix: ".stackwright/artifacts/", suffix: ".json", description: "Design language artifact" }
2166
3482
  ],
2167
3483
  "stackwright-pro-theme-otter": [
2168
- { prefix: ".stackwright/artifacts/", suffix: ".json", description: "Theme tokens artifact" }
3484
+ { prefix: ".stackwright/artifacts/", suffix: ".json", description: "Theme tokens artifact" },
3485
+ {
3486
+ prefix: "stackwright.theme.",
3487
+ suffix: ".yml",
3488
+ description: "Theme config YAML (merged at build time)"
3489
+ }
2169
3490
  ],
2170
3491
  "stackwright-pro-auth-otter": [
2171
3492
  { prefix: ".stackwright/artifacts/", suffix: ".json", description: "Auth config artifact" },
@@ -2175,6 +3496,16 @@ var OTTER_WRITE_ALLOWLISTS = {
2175
3496
  prefix: ".env",
2176
3497
  suffix: "",
2177
3498
  description: "Dotenv files (.env, .env.local, .env.production, etc.)"
3499
+ },
3500
+ {
3501
+ prefix: "lib/mock-auth",
3502
+ suffix: ".ts",
3503
+ description: "Mock auth module (DEV_ONLY_MODE role updates)"
3504
+ },
3505
+ {
3506
+ prefix: "package.json",
3507
+ suffix: ".json",
3508
+ description: "Project package.json (DEV_ONLY_MODE script cleanup)"
2178
3509
  }
2179
3510
  ],
2180
3511
  "stackwright-pro-data-otter": [
@@ -2198,13 +3529,109 @@ var OTTER_WRITE_ALLOWLISTS = {
2198
3529
  ],
2199
3530
  "stackwright-pro-api-otter": [
2200
3531
  { prefix: ".stackwright/artifacts/", suffix: ".json", description: "API config artifact" }
3532
+ ],
3533
+ "stackwright-pro-geo-otter": [
3534
+ { prefix: ".stackwright/artifacts/", suffix: ".json", description: "Geo manifest artifact" },
3535
+ { prefix: "pages/", suffix: "/content.yml", description: "Map page content" },
3536
+ { prefix: "pages/", suffix: "/content.yaml", description: "Map page content" }
3537
+ ],
3538
+ "stackwright-pro-polish-otter": [
3539
+ {
3540
+ prefix: "stackwright.yml",
3541
+ suffix: "",
3542
+ description: "Stackwright config (navigation updates)"
3543
+ },
3544
+ { prefix: "pages/", suffix: "/content.yml", description: "Landing page content" },
3545
+ { prefix: "pages/", suffix: "/content.yaml", description: "Landing page content" },
3546
+ { prefix: ".stackwright/artifacts/", suffix: ".json", description: "Polish artifact" },
3547
+ { prefix: "README.md", suffix: "", description: "Project README rewrite" }
3548
+ ],
3549
+ "stackwright-services-otter": [
3550
+ { prefix: ".stackwright/artifacts/", suffix: ".json", description: "Services config artifact" },
3551
+ { prefix: "services/", suffix: ".ts", description: "Service implementation files" },
3552
+ { prefix: "services/", suffix: ".yaml", description: "Service flow definitions" },
3553
+ { prefix: "services/", suffix: ".yml", description: "Service flow definitions" },
3554
+ { prefix: "lib/seeds/", suffix: ".ts", description: "Seed data files" },
3555
+ { prefix: "specs/", suffix: ".json", description: "Generated OpenAPI specs" },
3556
+ { prefix: "specs/", suffix: ".yaml", description: "Generated OpenAPI specs" },
3557
+ { prefix: "stackwright-generated/", suffix: ".json", description: "Services manifest" }
2201
3558
  ]
2202
3559
  };
2203
3560
  var PROTECTED_PATH_PREFIXES = [
2204
3561
  ".stackwright/pipeline-state.json",
3562
+ ".stackwright/pipeline-keys.json",
3563
+ // ephemeral signing keys
3564
+ ".stackwright/artifacts/signatures.json",
3565
+ // artifact signature manifest
2205
3566
  ".stackwright/questions/",
2206
- ".stackwright/answers/"
3567
+ ".stackwright/answers/",
3568
+ ".stackwright/page-registry.json"
3569
+ // page ownership — managed by safe_write internally
2207
3570
  ];
3571
+ var MAX_SAFE_WRITE_BYTES_JSON = 512 * 1024;
3572
+ var MAX_SAFE_WRITE_BYTES_YAML = 256 * 1024;
3573
+ var MAX_SAFE_WRITE_BYTES_ENV = 4 * 1024;
3574
+ var MAX_SAFE_WRITE_BYTES_DEFAULT = 256 * 1024;
3575
+ function getMaxBytesForPath(filePath) {
3576
+ if (filePath.endsWith(".json")) return { limit: MAX_SAFE_WRITE_BYTES_JSON, label: "JSON" };
3577
+ if (filePath.endsWith(".yml") || filePath.endsWith(".yaml"))
3578
+ return { limit: MAX_SAFE_WRITE_BYTES_YAML, label: "YAML" };
3579
+ if (filePath === ".env" || /^\.env\.[a-zA-Z0-9]+/.test(filePath))
3580
+ return { limit: MAX_SAFE_WRITE_BYTES_ENV, label: "env" };
3581
+ return { limit: MAX_SAFE_WRITE_BYTES_DEFAULT, label: "default" };
3582
+ }
3583
+ var PAGE_REGISTRY_FILE = ".stackwright/page-registry.json";
3584
+ function extractPageSlug(filePath) {
3585
+ const match = normalize(filePath).match(/^pages\/(.+?)\/content\.ya?ml$/);
3586
+ return match?.[1] ?? null;
3587
+ }
3588
+ function extractContentTypes(yamlContent) {
3589
+ const typePattern = /^\s*-?\s*type:\s*(\S+)/gm;
3590
+ const types = [];
3591
+ let m;
3592
+ while ((m = typePattern.exec(yamlContent)) !== null) {
3593
+ const typeName = m[1];
3594
+ if (typeName && !types.includes(typeName)) {
3595
+ types.push(typeName);
3596
+ }
3597
+ }
3598
+ return types.length > 0 ? types : ["unknown"];
3599
+ }
3600
+ function readPageRegistry(cwd) {
3601
+ const regPath = join5(cwd, PAGE_REGISTRY_FILE);
3602
+ if (!existsSync6(regPath)) return {};
3603
+ try {
3604
+ const stat = lstatSync6(regPath);
3605
+ if (stat.isSymbolicLink()) return {};
3606
+ return JSON.parse(readFileSync5(regPath, "utf-8"));
3607
+ } catch {
3608
+ return {};
3609
+ }
3610
+ }
3611
+ function writePageRegistry(cwd, registry) {
3612
+ const dir = join5(cwd, ".stackwright");
3613
+ mkdirSync5(dir, { recursive: true });
3614
+ const regPath = join5(cwd, PAGE_REGISTRY_FILE);
3615
+ if (existsSync6(regPath)) {
3616
+ const stat = lstatSync6(regPath);
3617
+ if (stat.isSymbolicLink()) {
3618
+ throw new Error("Refusing to write page registry through symlink");
3619
+ }
3620
+ }
3621
+ writeFileSync5(regPath, JSON.stringify(registry, null, 2) + "\n", { encoding: "utf-8" });
3622
+ }
3623
+ function checkPageOwnership(cwd, slug, callerOtter) {
3624
+ const registry = readPageRegistry(cwd);
3625
+ const existing = registry[slug];
3626
+ if (!existing || existing.claimedBy === callerOtter) {
3627
+ return { allowed: true };
3628
+ }
3629
+ return {
3630
+ allowed: false,
3631
+ owner: existing.claimedBy,
3632
+ contentTypes: existing.contentTypes
3633
+ };
3634
+ }
2208
3635
  function checkPathAllowed(callerOtter, filePath) {
2209
3636
  const normalized = normalize(filePath);
2210
3637
  if (normalized.includes("..")) {
@@ -2246,6 +3673,16 @@ function checkPathAllowed(callerOtter, filePath) {
2246
3673
  continue;
2247
3674
  }
2248
3675
  }
3676
+ if (rule.prefix === "lib/mock-auth" && rule.suffix === ".ts") {
3677
+ if (normalized !== "lib/mock-auth.ts") {
3678
+ continue;
3679
+ }
3680
+ }
3681
+ if (rule.prefix === "package.json" && rule.suffix === ".json") {
3682
+ if (normalized !== "package.json") {
3683
+ continue;
3684
+ }
3685
+ }
2249
3686
  return { allowed: true, rule: rule.description };
2250
3687
  }
2251
3688
  }
@@ -2330,11 +3767,23 @@ function handleSafeWrite(input) {
2330
3767
  };
2331
3768
  return { text: JSON.stringify(result), isError: true };
2332
3769
  }
3770
+ const contentBytes = Buffer.byteLength(content, "utf-8");
3771
+ const { limit: maxBytes, label: fileTypeLabel } = getMaxBytesForPath(filePath);
3772
+ if (contentBytes > maxBytes) {
3773
+ const result = {
3774
+ success: false,
3775
+ error: `Content size ${contentBytes} bytes exceeds ${fileTypeLabel} limit of ${maxBytes} bytes (${maxBytes / 1024} KB)`,
3776
+ callerOtter,
3777
+ attemptedPath: filePath,
3778
+ allowedPaths: []
3779
+ };
3780
+ return { text: JSON.stringify(result), isError: true };
3781
+ }
2333
3782
  const normalized = normalize(filePath);
2334
- const fullPath = join4(cwd, normalized);
2335
- if (existsSync4(fullPath)) {
3783
+ const fullPath = join5(cwd, normalized);
3784
+ if (existsSync6(fullPath)) {
2336
3785
  try {
2337
- const stat = lstatSync4(fullPath);
3786
+ const stat = lstatSync6(fullPath);
2338
3787
  if (stat.isSymbolicLink()) {
2339
3788
  const result = {
2340
3789
  success: false,
@@ -2359,11 +3808,38 @@ function handleSafeWrite(input) {
2359
3808
  };
2360
3809
  return { text: JSON.stringify(result), isError: true };
2361
3810
  }
3811
+ const pageSlug = extractPageSlug(normalized);
3812
+ if (pageSlug) {
3813
+ const ownership = checkPageOwnership(cwd, pageSlug, callerOtter);
3814
+ if (!ownership.allowed) {
3815
+ const result = {
3816
+ success: false,
3817
+ error: `Page slug "${pageSlug}" is already claimed by ${ownership.owner} (content types: ${ownership.contentTypes.join(", ")}). Use a different slug or coordinate with the owning otter.`,
3818
+ callerOtter,
3819
+ attemptedPath: filePath,
3820
+ allowedPaths: []
3821
+ };
3822
+ return { text: JSON.stringify(result), isError: true };
3823
+ }
3824
+ }
2362
3825
  try {
2363
3826
  if (createDirectories) {
2364
- mkdirSync3(dirname(fullPath), { recursive: true });
3827
+ mkdirSync5(dirname(fullPath), { recursive: true });
3828
+ }
3829
+ writeFileSync5(fullPath, content, { encoding: "utf-8" });
3830
+ if (pageSlug) {
3831
+ try {
3832
+ const registry = readPageRegistry(cwd);
3833
+ registry[pageSlug] = {
3834
+ slug: pageSlug,
3835
+ claimedBy: callerOtter,
3836
+ contentTypes: extractContentTypes(content),
3837
+ writtenAt: (/* @__PURE__ */ new Date()).toISOString()
3838
+ };
3839
+ writePageRegistry(cwd, registry);
3840
+ } catch {
3841
+ }
2365
3842
  }
2366
- writeFileSync4(fullPath, content, { encoding: "utf-8" });
2367
3843
  const result = {
2368
3844
  success: true,
2369
3845
  path: normalized,
@@ -2389,10 +3865,12 @@ function registerSafeWriteTools(server2) {
2389
3865
  "stackwright_pro_safe_write",
2390
3866
  DESC,
2391
3867
  {
2392
- callerOtter: z10.string().describe('The otter agent name requesting the write, e.g. "stackwright-pro-page-otter"'),
2393
- filePath: z10.string().describe('Relative path from project root, e.g. "pages/dashboard/content.yml"'),
2394
- content: z10.string().describe("File content to write"),
2395
- createDirectories: z10.boolean().optional().describe("Create parent directories if they don't exist. Default: true")
3868
+ callerOtter: z12.string().describe('The otter agent name requesting the write, e.g. "stackwright-pro-page-otter"'),
3869
+ filePath: z12.string().describe('Relative path from project root, e.g. "pages/dashboard/content.yml"'),
3870
+ content: z12.string().describe("File content to write"),
3871
+ createDirectories: boolCoerce(z12.boolean().optional().default(true)).describe(
3872
+ "Create parent directories if they don't exist. Default: true"
3873
+ )
2396
3874
  },
2397
3875
  async ({ callerOtter, filePath, content, createDirectories }) => {
2398
3876
  const result = handleSafeWrite({
@@ -2407,9 +3885,9 @@ function registerSafeWriteTools(server2) {
2407
3885
  }
2408
3886
 
2409
3887
  // src/tools/auth.ts
2410
- import { z as z11 } from "zod";
2411
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync5 } from "fs";
2412
- import { join as join5 } from "path";
3888
+ import { z as z13 } from "zod";
3889
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync7, mkdirSync as mkdirSync6 } from "fs";
3890
+ import { join as join6 } from "path";
2413
3891
  function buildHierarchy(roles) {
2414
3892
  const h = {};
2415
3893
  for (let i = 0; i < roles.length - 1; i++) {
@@ -2530,6 +4008,91 @@ ${auditBlock}
2530
4008
  ${routesBlock}
2531
4009
  });
2532
4010
 
4011
+ ${configBlock}
4012
+ `;
4013
+ }
4014
+ function generateProxyContent(method, params, roles, defaultRole, hierarchy, auditEnabled, auditRetentionDays, protectedRoutes) {
4015
+ const rbacBlock = ` rbac: {
4016
+ roles: ${JSON.stringify(roles)},
4017
+ defaultRole: '${defaultRole}',
4018
+ hierarchy: ${JSON.stringify(hierarchy, null, 4)},
4019
+ },`;
4020
+ const auditBlock = ` audit: {
4021
+ enabled: ${auditEnabled},
4022
+ retentionDays: ${auditRetentionDays},
4023
+ },`;
4024
+ const routesBlock = ` protectedRoutes: ${JSON.stringify(protectedRoutes)},`;
4025
+ const configBlock = `export const config = {
4026
+ matcher: ${JSON.stringify(protectedRoutes)},
4027
+ };`;
4028
+ if (method === "cac") {
4029
+ const caBundle = params.cacCaBundle ?? "./certs/dod-ca-bundle.pem";
4030
+ const edipiLookup = params.cacEdipiLookup ?? "./config/edipi-lookup.json";
4031
+ const ocspEndpoint = params.cacOcspEndpoint ?? "https://ocsp.disa.mil";
4032
+ const certHeader = params.cacCertHeader ?? "X-SSL-Client-Cert";
4033
+ return `// proxy.ts \u2014 generated by @stackwright-pro/auth (Next.js >=16)
4034
+ // SECURITY REVIEW REQUIRED \u2014 CAC/PKI certificate validation
4035
+ // DoD security officer review required before production deployment.
4036
+ // Verify: CA bundle completeness, EDIPI lookup endpoint, OCSP accessibility.
4037
+ import { createAuthProxy } from '@stackwright-pro/auth-nextjs';
4038
+
4039
+ export const proxy = createAuthProxy({
4040
+ method: 'cac',
4041
+ cac: {
4042
+ caBundle: process.env.CAC_CA_BUNDLE ?? '${caBundle}',
4043
+ edipiLookup: '${edipiLookup}',
4044
+ ocspEndpoint: process.env.CAC_OCSP_ENDPOINT ?? '${ocspEndpoint}',
4045
+ certHeader: '${certHeader}',
4046
+ },
4047
+ ${rbacBlock}
4048
+ ${auditBlock}
4049
+ ${routesBlock}
4050
+ });
4051
+
4052
+ ${configBlock}
4053
+ `;
4054
+ }
4055
+ if (method === "oidc") {
4056
+ const scopes2 = params.oidcScopes ?? "openid profile email";
4057
+ const roleClaim = params.oidcRoleClaim ?? "roles";
4058
+ return `// proxy.ts \u2014 generated by @stackwright-pro/auth (Next.js >=16)
4059
+ import { createAuthProxy } from '@stackwright-pro/auth-nextjs';
4060
+
4061
+ export const proxy = createAuthProxy({
4062
+ method: 'oidc',
4063
+ oidc: {
4064
+ discoveryUrl: process.env.OIDC_DISCOVERY_URL!,
4065
+ clientId: process.env.OIDC_CLIENT_ID!,
4066
+ clientSecret: process.env.OIDC_CLIENT_SECRET!,
4067
+ scopes: '${scopes2}',
4068
+ roleClaim: '${roleClaim}',
4069
+ },
4070
+ ${rbacBlock}
4071
+ ${auditBlock}
4072
+ ${routesBlock}
4073
+ });
4074
+
4075
+ ${configBlock}
4076
+ `;
4077
+ }
4078
+ const scopes = params.oauth2Scopes ?? "read write";
4079
+ return `// proxy.ts \u2014 generated by @stackwright-pro/auth (Next.js >=16)
4080
+ import { createAuthProxy } from '@stackwright-pro/auth-nextjs';
4081
+
4082
+ export const proxy = createAuthProxy({
4083
+ method: 'oauth2',
4084
+ oauth2: {
4085
+ authorizationUrl: process.env.OAUTH2_AUTH_URL!,
4086
+ tokenUrl: process.env.OAUTH2_TOKEN_URL!,
4087
+ clientId: process.env.OAUTH2_CLIENT_ID!,
4088
+ clientSecret: process.env.OAUTH2_CLIENT_SECRET!,
4089
+ scopes: '${scopes}',
4090
+ },
4091
+ ${rbacBlock}
4092
+ ${auditBlock}
4093
+ ${routesBlock}
4094
+ });
4095
+
2533
4096
  ${configBlock}
2534
4097
  `;
2535
4098
  }
@@ -2559,7 +4122,7 @@ OAUTH2_CLIENT_ID=your-client-id
2559
4122
  OAUTH2_CLIENT_SECRET=your-client-secret
2560
4123
  `;
2561
4124
  }
2562
- function generateYamlBlock(method, params, roles, defaultRole, hierarchy, auditEnabled, auditRetentionDays, protectedRoutes) {
4125
+ function generateYamlBlock(method, params, roles, defaultRole, hierarchy, auditEnabled, auditRetentionDays, protectedRoutes, useProxy) {
2563
4126
  const rbacSection = ` rbac:
2564
4127
  roles:
2565
4128
  ${rolesToYaml(roles, " ")}
@@ -2582,7 +4145,7 @@ ${routesToYaml(protectedRoutes, " ", defaultRole)}`.replace(/\n\s+,/g, "");
2582
4145
  const certHeader = params.cacCertHeader ?? "X-SSL-Client-Cert";
2583
4146
  return `auth:
2584
4147
  method: cac
2585
- ${providerLine} middleware: ./middleware.ts
4148
+ ${providerLine} ${useProxy ? "proxy" : "middleware"}: ./${useProxy ? "proxy" : "middleware"}.ts
2586
4149
  cac:
2587
4150
  caBundle: \${CAC_CA_BUNDLE}
2588
4151
  edipiLookup: ${edipiLookup}
@@ -2599,7 +4162,7 @@ ${auditSection}
2599
4162
  const roleClaim = params.oidcRoleClaim ?? "roles";
2600
4163
  return `auth:
2601
4164
  method: oidc
2602
- ${providerLine} middleware: ./middleware.ts
4165
+ ${providerLine} ${useProxy ? "proxy" : "middleware"}: ./${useProxy ? "proxy" : "middleware"}.ts
2603
4166
  oidc:
2604
4167
  discoveryUrl: \${OIDC_DISCOVERY_URL}
2605
4168
  clientId: \${OIDC_CLIENT_ID}
@@ -2615,7 +4178,7 @@ ${auditSection}
2615
4178
  const scopes = params.oauth2Scopes ?? "read write";
2616
4179
  return `auth:
2617
4180
  method: oauth2
2618
- ${providerLine} middleware: ./middleware.ts
4181
+ ${providerLine} ${useProxy ? "proxy" : "middleware"}: ./${useProxy ? "proxy" : "middleware"}.ts
2619
4182
  oauth2:
2620
4183
  authorizationUrl: \${OAUTH2_AUTH_URL}
2621
4184
  tokenUrl: \${OAUTH2_TOKEN_URL}
@@ -2628,10 +4191,321 @@ ${routeLines}
2628
4191
  ${auditSection}
2629
4192
  `;
2630
4193
  }
4194
+ function generateDevOnlyMiddlewareContent(method, params, roles, defaultRole, hierarchy, auditEnabled, auditRetentionDays, protectedRoutes) {
4195
+ const rbacBlock = ` rbac: {
4196
+ roles: ${JSON.stringify(roles)},
4197
+ defaultRole: '${defaultRole}',
4198
+ hierarchy: ${JSON.stringify(hierarchy, null, 4)},
4199
+ },`;
4200
+ const auditBlock = ` audit: {
4201
+ enabled: ${auditEnabled},
4202
+ retentionDays: ${auditRetentionDays},
4203
+ },`;
4204
+ const routesBlock = ` protectedRoutes: ${JSON.stringify(protectedRoutes)},`;
4205
+ const configBlock = `export const config = {
4206
+ matcher: ${JSON.stringify(protectedRoutes)},
4207
+ };`;
4208
+ const devHeader = [
4209
+ "// middleware.ts \u2014 generated by @stackwright-pro/auth-nextjs (dev-only mock)",
4210
+ "// DEV ONLY \u2014 uses mock authentication from lib/mock-auth.ts",
4211
+ "// Do NOT deploy to production.",
4212
+ "import { createProMiddleware } from '@stackwright-pro/auth-nextjs';",
4213
+ "import { mockAuthProvider } from './lib/mock-auth';"
4214
+ ].join("\n");
4215
+ if (method === "cac") {
4216
+ const caBundle = params.cacCaBundle ?? "./certs/dod-ca-bundle.pem";
4217
+ const edipiLookup = params.cacEdipiLookup ?? "./config/edipi-lookup.json";
4218
+ const ocspEndpoint = params.cacOcspEndpoint ?? "https://ocsp.disa.mil";
4219
+ const certHeader = params.cacCertHeader ?? "X-SSL-Client-Cert";
4220
+ return `${devHeader}
4221
+
4222
+ export const middleware = createProMiddleware({
4223
+ method: 'cac',
4224
+ cac: {
4225
+ caBundle: '${caBundle}',
4226
+ edipiLookup: '${edipiLookup}',
4227
+ ocspEndpoint: '${ocspEndpoint}',
4228
+ certHeader: '${certHeader}',
4229
+ provider: mockAuthProvider,
4230
+ },
4231
+ ${rbacBlock}
4232
+ ${auditBlock}
4233
+ ${routesBlock}
4234
+ });
4235
+
4236
+ ${configBlock}
4237
+ `;
4238
+ }
4239
+ if (method === "oidc") {
4240
+ const scopes2 = params.oidcScopes ?? "openid profile email";
4241
+ const roleClaim = params.oidcRoleClaim ?? "roles";
4242
+ return `${devHeader}
4243
+
4244
+ export const middleware = createProMiddleware({
4245
+ method: 'oidc',
4246
+ oidc: {
4247
+ discoveryUrl: 'https://dev-mock-oidc/.well-known/openid-configuration',
4248
+ clientId: 'dev-mock-client',
4249
+ clientSecret: 'dev-mock-secret',
4250
+ scopes: '${scopes2}',
4251
+ roleClaim: '${roleClaim}',
4252
+ provider: mockAuthProvider,
4253
+ },
4254
+ ${rbacBlock}
4255
+ ${auditBlock}
4256
+ ${routesBlock}
4257
+ });
4258
+
4259
+ ${configBlock}
4260
+ `;
4261
+ }
4262
+ const scopes = params.oauth2Scopes ?? "read write";
4263
+ return `${devHeader}
4264
+
4265
+ export const middleware = createProMiddleware({
4266
+ method: 'oauth2',
4267
+ oauth2: {
4268
+ authorizationUrl: 'https://dev-mock-oauth2/authorize',
4269
+ tokenUrl: 'https://dev-mock-oauth2/token',
4270
+ clientId: 'dev-mock-client',
4271
+ clientSecret: 'dev-mock-secret',
4272
+ scopes: '${scopes}',
4273
+ provider: mockAuthProvider,
4274
+ },
4275
+ ${rbacBlock}
4276
+ ${auditBlock}
4277
+ ${routesBlock}
4278
+ });
4279
+
4280
+ ${configBlock}
4281
+ `;
4282
+ }
4283
+ function generateDevOnlyProxyContent(method, params, roles, defaultRole, hierarchy, auditEnabled, auditRetentionDays, protectedRoutes) {
4284
+ const rbacBlock = ` rbac: {
4285
+ roles: ${JSON.stringify(roles)},
4286
+ defaultRole: '${defaultRole}',
4287
+ hierarchy: ${JSON.stringify(hierarchy, null, 4)},
4288
+ },`;
4289
+ const auditBlock = ` audit: {
4290
+ enabled: ${auditEnabled},
4291
+ retentionDays: ${auditRetentionDays},
4292
+ },`;
4293
+ const routesBlock = ` protectedRoutes: ${JSON.stringify(protectedRoutes)},`;
4294
+ const configBlock = `export const config = {
4295
+ matcher: ${JSON.stringify(protectedRoutes)},
4296
+ };`;
4297
+ const devHeader = [
4298
+ "// proxy.ts -- generated by @stackwright-pro/auth (Next.js >=16, dev-only mock)",
4299
+ "// DEV ONLY -- uses mock authentication from lib/mock-auth.ts",
4300
+ "// Do NOT deploy to production.",
4301
+ "import { createAuthProxy } from '@stackwright-pro/auth-nextjs';",
4302
+ "import { mockAuthProvider } from './lib/mock-auth';"
4303
+ ].join("\n");
4304
+ if (method === "cac") {
4305
+ const caBundle = params.cacCaBundle ?? "./certs/dod-ca-bundle.pem";
4306
+ const edipiLookup = params.cacEdipiLookup ?? "./config/edipi-lookup.json";
4307
+ const ocspEndpoint = params.cacOcspEndpoint ?? "https://ocsp.disa.mil";
4308
+ const certHeader = params.cacCertHeader ?? "X-SSL-Client-Cert";
4309
+ return `${devHeader}
4310
+
4311
+ export const proxy = createAuthProxy({
4312
+ method: 'cac',
4313
+ cac: {
4314
+ caBundle: '${caBundle}',
4315
+ edipiLookup: '${edipiLookup}',
4316
+ ocspEndpoint: '${ocspEndpoint}',
4317
+ certHeader: '${certHeader}',
4318
+ provider: mockAuthProvider,
4319
+ },
4320
+ ${rbacBlock}
4321
+ ${auditBlock}
4322
+ ${routesBlock}
4323
+ });
4324
+
4325
+ ${configBlock}
4326
+ `;
4327
+ }
4328
+ if (method === "oidc") {
4329
+ const scopes2 = params.oidcScopes ?? "openid profile email";
4330
+ const roleClaim = params.oidcRoleClaim ?? "roles";
4331
+ return `${devHeader}
4332
+
4333
+ export const proxy = createAuthProxy({
4334
+ method: 'oidc',
4335
+ oidc: {
4336
+ discoveryUrl: 'https://dev-mock-oidc/.well-known/openid-configuration',
4337
+ clientId: 'dev-mock-client',
4338
+ clientSecret: 'dev-mock-secret',
4339
+ scopes: '${scopes2}',
4340
+ roleClaim: '${roleClaim}',
4341
+ provider: mockAuthProvider,
4342
+ },
4343
+ ${rbacBlock}
4344
+ ${auditBlock}
4345
+ ${routesBlock}
4346
+ });
4347
+
4348
+ ${configBlock}
4349
+ `;
4350
+ }
4351
+ const scopes = params.oauth2Scopes ?? "read write";
4352
+ return `${devHeader}
4353
+
4354
+ export const proxy = createAuthProxy({
4355
+ method: 'oauth2',
4356
+ oauth2: {
4357
+ authorizationUrl: 'https://dev-mock-oauth2/authorize',
4358
+ tokenUrl: 'https://dev-mock-oauth2/token',
4359
+ clientId: 'dev-mock-client',
4360
+ clientSecret: 'dev-mock-secret',
4361
+ scopes: '${scopes}',
4362
+ provider: mockAuthProvider,
4363
+ },
4364
+ ${rbacBlock}
4365
+ ${auditBlock}
4366
+ ${routesBlock}
4367
+ });
4368
+
4369
+ ${configBlock}
4370
+ `;
4371
+ }
4372
+ function generateDevOnlyYamlBlock(method, params, roles, defaultRole, hierarchy, auditEnabled, auditRetentionDays, protectedRoutes, useProxy) {
4373
+ const rbacSection = ` rbac:
4374
+ roles:
4375
+ ${rolesToYaml(roles, " ")}
4376
+ defaultRole: ${defaultRole}
4377
+ hierarchy:
4378
+ ${hierarchyToYaml(hierarchy, " ")}`;
4379
+ const auditSection = ` audit:
4380
+ enabled: ${auditEnabled}
4381
+ retentionDays: ${auditRetentionDays}`;
4382
+ const routeLines = protectedRoutes.map((r) => ` - pattern: ${r}
4383
+ requiredRole: ${defaultRole}`).join("\n");
4384
+ const providerLine = params.provider ? ` provider: ${params.provider}
4385
+ ` : "";
4386
+ if (method === "cac") {
4387
+ const caBundle = params.cacCaBundle ?? "./certs/dod-ca-bundle.pem";
4388
+ const edipiLookup = params.cacEdipiLookup ?? "./config/edipi-lookup.json";
4389
+ const ocspEndpoint = params.cacOcspEndpoint ?? "https://ocsp.disa.mil";
4390
+ const certHeader = params.cacCertHeader ?? "X-SSL-Client-Cert";
4391
+ return `auth:
4392
+ method: cac
4393
+ devOnly: true
4394
+ ${providerLine} ${useProxy ? "proxy" : "middleware"}: ./${useProxy ? "proxy" : "middleware"}.ts
4395
+ cac:
4396
+ caBundle: ${caBundle}
4397
+ edipiLookup: ${edipiLookup}
4398
+ ocspEndpoint: ${ocspEndpoint}
4399
+ certHeader: ${certHeader}
4400
+ ${rbacSection}
4401
+ protectedRoutes:
4402
+ ${routeLines}
4403
+ ${auditSection}
4404
+ `;
4405
+ }
4406
+ if (method === "oidc") {
4407
+ const scopes2 = params.oidcScopes ?? "openid profile email";
4408
+ const roleClaim = params.oidcRoleClaim ?? "roles";
4409
+ return `auth:
4410
+ method: oidc
4411
+ devOnly: true
4412
+ ${providerLine} ${useProxy ? "proxy" : "middleware"}: ./${useProxy ? "proxy" : "middleware"}.ts
4413
+ oidc:
4414
+ discoveryUrl: https://dev-mock-oidc/.well-known/openid-configuration
4415
+ clientId: dev-mock-client
4416
+ clientSecret: dev-mock-secret
4417
+ scopes: ${scopes2}
4418
+ roleClaim: ${roleClaim}
4419
+ ${rbacSection}
4420
+ protectedRoutes:
4421
+ ${routeLines}
4422
+ ${auditSection}
4423
+ `;
4424
+ }
4425
+ const scopes = params.oauth2Scopes ?? "read write";
4426
+ return `auth:
4427
+ method: oauth2
4428
+ devOnly: true
4429
+ ${providerLine} ${useProxy ? "proxy" : "middleware"}: ./${useProxy ? "proxy" : "middleware"}.ts
4430
+ oauth2:
4431
+ authorizationUrl: https://dev-mock-oauth2/authorize
4432
+ tokenUrl: https://dev-mock-oauth2/token
4433
+ clientId: dev-mock-client
4434
+ clientSecret: dev-mock-secret
4435
+ scopes: ${scopes}
4436
+ ${rbacSection}
4437
+ protectedRoutes:
4438
+ ${routeLines}
4439
+ ${auditSection}
4440
+ `;
4441
+ }
4442
+ function deriveDevKey(roleName) {
4443
+ const segment = roleName.split("_")[0];
4444
+ return (segment ?? roleName).toLowerCase();
4445
+ }
4446
+ function generateMockAuthContent(roles, mockUsers) {
4447
+ const entries = [];
4448
+ const scriptLines = [];
4449
+ for (let i = 0; i < roles.length; i++) {
4450
+ const role = roles[i];
4451
+ const devKey = deriveDevKey(role);
4452
+ const persona = mockUsers?.[i];
4453
+ const name = persona?.name ?? `Dev ${role}`;
4454
+ const email = persona?.email ?? `dev-${devKey}@example.mil`;
4455
+ const edipi = String(i + 1).padStart(10, "0");
4456
+ entries.push(` ${devKey}: {
4457
+ id: 'mock-${devKey}-${String(i + 1).padStart(3, "0")}',
4458
+ name: '${name}',
4459
+ email: '${email}',
4460
+ roles: ['${role}'],
4461
+ edipi: '${edipi}',
4462
+ }`);
4463
+ scriptLines.push(` * pnpm dev:${devKey} \u2014 ${name}`);
4464
+ }
4465
+ return `/**
4466
+ * Mock authentication for development mode.
4467
+ *
4468
+ * DEV_ONLY_MODE \u2014 no real auth provider. Select a persona via MOCK_USER env var
4469
+ * or use the convenience scripts in package.json:
4470
+ ${scriptLines.join("\n")}
4471
+ */
4472
+
4473
+ export const MOCK_USERS = {
4474
+ ${entries.join(",\n")}
4475
+ } as const;
4476
+
4477
+ export type MockRole = keyof typeof MOCK_USERS;
4478
+
4479
+ export function getMockUser(role?: string) {
4480
+ const key = (role ?? process.env.MOCK_USER) as MockRole | undefined;
4481
+ if (!key) return null;
4482
+ return MOCK_USERS[key] ?? null;
4483
+ }
4484
+
4485
+ export function mockAuthProvider() {
4486
+ return getMockUser();
4487
+ }
4488
+ `;
4489
+ }
4490
+ function updatePackageJsonScripts(existingJson, roles, mockUsers) {
4491
+ const pkg = JSON.parse(existingJson);
4492
+ const scripts = pkg.scripts ?? {};
4493
+ delete scripts["dev:admin"];
4494
+ delete scripts["dev:analyst"];
4495
+ delete scripts["dev:viewer"];
4496
+ for (let i = 0; i < roles.length; i++) {
4497
+ const devKey = deriveDevKey(roles[i]);
4498
+ scripts[`dev:${devKey}`] = `MOCK_USER=${devKey} next dev`;
4499
+ }
4500
+ pkg.scripts = scripts;
4501
+ return JSON.stringify(pkg, null, 2) + "\n";
4502
+ }
2631
4503
  async function configureAuthHandler(params, cwd) {
2632
4504
  const {
2633
4505
  method,
2634
4506
  provider,
4507
+ devOnly = false,
4508
+ mockUsers,
2635
4509
  rbacRoles = ["SUPER_ADMIN", "ADMIN", "ANALYST"],
2636
4510
  auditEnabled = true,
2637
4511
  auditRetentionDays = 90,
@@ -2640,6 +4514,8 @@ async function configureAuthHandler(params, cwd) {
2640
4514
  const roles = rbacRoles;
2641
4515
  const defaultRole = params.rbacDefaultRole ?? roles[roles.length - 1];
2642
4516
  const hierarchy = buildHierarchy(roles);
4517
+ const useProxy = (params.nextMajorVersion ?? 0) >= 16;
4518
+ const conventionFile = useProxy ? "proxy.ts" : "middleware.ts";
2643
4519
  if (method === "none") {
2644
4520
  return {
2645
4521
  content: [
@@ -2661,7 +4537,34 @@ async function configureAuthHandler(params, cwd) {
2661
4537
  }
2662
4538
  const filesWritten = [];
2663
4539
  try {
2664
- const middlewareContent = generateMiddlewareContent(
4540
+ const authFileContent = devOnly ? useProxy ? generateDevOnlyProxyContent(
4541
+ method,
4542
+ params,
4543
+ roles,
4544
+ defaultRole,
4545
+ hierarchy,
4546
+ auditEnabled,
4547
+ auditRetentionDays,
4548
+ protectedRoutes
4549
+ ) : generateDevOnlyMiddlewareContent(
4550
+ method,
4551
+ params,
4552
+ roles,
4553
+ defaultRole,
4554
+ hierarchy,
4555
+ auditEnabled,
4556
+ auditRetentionDays,
4557
+ protectedRoutes
4558
+ ) : useProxy ? generateProxyContent(
4559
+ method,
4560
+ params,
4561
+ roles,
4562
+ defaultRole,
4563
+ hierarchy,
4564
+ auditEnabled,
4565
+ auditRetentionDays,
4566
+ protectedRoutes
4567
+ ) : generateMiddlewareContent(
2665
4568
  method,
2666
4569
  params,
2667
4570
  roles,
@@ -2671,44 +4574,95 @@ async function configureAuthHandler(params, cwd) {
2671
4574
  auditRetentionDays,
2672
4575
  protectedRoutes
2673
4576
  );
2674
- writeFileSync5(join5(cwd, "middleware.ts"), middlewareContent, "utf8");
2675
- filesWritten.push("middleware.ts");
4577
+ writeFileSync6(join6(cwd, conventionFile), authFileContent, "utf8");
4578
+ filesWritten.push(conventionFile);
2676
4579
  } catch (err) {
2677
4580
  const msg = err instanceof Error ? err.message : String(err);
2678
4581
  return {
2679
4582
  content: [
2680
4583
  {
2681
4584
  type: "text",
2682
- text: JSON.stringify({ success: false, error: `Failed writing middleware.ts: ${msg}` })
4585
+ text: JSON.stringify({
4586
+ success: false,
4587
+ error: `Failed writing ${conventionFile}: ${msg}`
4588
+ })
2683
4589
  }
2684
4590
  ],
2685
4591
  isError: true
2686
4592
  };
2687
4593
  }
2688
- try {
2689
- const envBlock = generateEnvBlock(method, params);
2690
- const envPath = join5(cwd, ".env.example");
2691
- if (existsSync5(envPath)) {
2692
- const existing = readFileSync4(envPath, "utf8");
2693
- writeFileSync5(envPath, existing.trimEnd() + "\n\n" + envBlock, "utf8");
2694
- } else {
2695
- writeFileSync5(envPath, envBlock, "utf8");
4594
+ if (!devOnly) {
4595
+ try {
4596
+ const envBlock = generateEnvBlock(method, params);
4597
+ const envPath = join6(cwd, ".env.example");
4598
+ if (existsSync7(envPath)) {
4599
+ const existing = readFileSync6(envPath, "utf8");
4600
+ writeFileSync6(envPath, existing.trimEnd() + "\n\n" + envBlock, "utf8");
4601
+ } else {
4602
+ writeFileSync6(envPath, envBlock, "utf8");
4603
+ }
4604
+ filesWritten.push(".env.example");
4605
+ } catch (err) {
4606
+ const msg = err instanceof Error ? err.message : String(err);
4607
+ return {
4608
+ content: [
4609
+ {
4610
+ type: "text",
4611
+ text: JSON.stringify({ success: false, error: `Failed writing .env.example: ${msg}` })
4612
+ }
4613
+ ],
4614
+ isError: true
4615
+ };
4616
+ }
4617
+ }
4618
+ if (devOnly) {
4619
+ try {
4620
+ const mockAuthDir = join6(cwd, "lib");
4621
+ mkdirSync6(mockAuthDir, { recursive: true });
4622
+ const mockAuthContent = generateMockAuthContent(roles, mockUsers);
4623
+ writeFileSync6(join6(mockAuthDir, "mock-auth.ts"), mockAuthContent, "utf8");
4624
+ filesWritten.push("lib/mock-auth.ts");
4625
+ } catch (err) {
4626
+ const msg = err instanceof Error ? err.message : String(err);
4627
+ return {
4628
+ content: [
4629
+ {
4630
+ type: "text",
4631
+ text: JSON.stringify({
4632
+ success: false,
4633
+ error: `Failed writing lib/mock-auth.ts: ${msg}`
4634
+ })
4635
+ }
4636
+ ],
4637
+ isError: true
4638
+ };
4639
+ }
4640
+ try {
4641
+ const pkgPath = join6(cwd, "package.json");
4642
+ if (existsSync7(pkgPath)) {
4643
+ const existingPkg = readFileSync6(pkgPath, "utf8");
4644
+ const updatedPkg = updatePackageJsonScripts(existingPkg, roles, mockUsers);
4645
+ writeFileSync6(pkgPath, updatedPkg, "utf8");
4646
+ filesWritten.push("package.json");
4647
+ }
4648
+ } catch (err) {
4649
+ const msg = err instanceof Error ? err.message : String(err);
4650
+ return {
4651
+ content: [
4652
+ {
4653
+ type: "text",
4654
+ text: JSON.stringify({
4655
+ success: false,
4656
+ error: `Failed updating package.json: ${msg}`
4657
+ })
4658
+ }
4659
+ ],
4660
+ isError: true
4661
+ };
2696
4662
  }
2697
- filesWritten.push(".env.example");
2698
- } catch (err) {
2699
- const msg = err instanceof Error ? err.message : String(err);
2700
- return {
2701
- content: [
2702
- {
2703
- type: "text",
2704
- text: JSON.stringify({ success: false, error: `Failed writing .env.example: ${msg}` })
2705
- }
2706
- ],
2707
- isError: true
2708
- };
2709
4663
  }
2710
4664
  try {
2711
- const authYaml = generateYamlBlock(
4665
+ const authYaml = devOnly ? generateDevOnlyYamlBlock(
2712
4666
  method,
2713
4667
  params,
2714
4668
  roles,
@@ -2716,14 +4670,25 @@ async function configureAuthHandler(params, cwd) {
2716
4670
  hierarchy,
2717
4671
  auditEnabled,
2718
4672
  auditRetentionDays,
2719
- protectedRoutes
4673
+ protectedRoutes,
4674
+ useProxy
4675
+ ) : generateYamlBlock(
4676
+ method,
4677
+ params,
4678
+ roles,
4679
+ defaultRole,
4680
+ hierarchy,
4681
+ auditEnabled,
4682
+ auditRetentionDays,
4683
+ protectedRoutes,
4684
+ useProxy
2720
4685
  );
2721
- const ymlPath = join5(cwd, "stackwright.yml");
2722
- if (!existsSync5(ymlPath)) {
2723
- writeFileSync5(ymlPath, authYaml, "utf8");
4686
+ const ymlPath = join6(cwd, "stackwright.yml");
4687
+ if (!existsSync7(ymlPath)) {
4688
+ writeFileSync6(ymlPath, authYaml, "utf8");
2724
4689
  } else {
2725
- const existing = readFileSync4(ymlPath, "utf8");
2726
- writeFileSync5(ymlPath, upsertAuthBlock(existing, authYaml), "utf8");
4690
+ const existing = readFileSync6(ymlPath, "utf8");
4691
+ writeFileSync6(ymlPath, upsertAuthBlock(existing, authYaml), "utf8");
2727
4692
  }
2728
4693
  filesWritten.push("stackwright.yml");
2729
4694
  } catch (err) {
@@ -2751,7 +4716,23 @@ async function configureAuthHandler(params, cwd) {
2751
4716
  rbacDefaultRole: defaultRole,
2752
4717
  protectedRoutesCount: protectedRoutes.length,
2753
4718
  filesWritten,
2754
- securityWarning
4719
+ convention: useProxy ? "proxy" : "middleware",
4720
+ nextMajorVersion: params.nextMajorVersion ?? null,
4721
+ securityWarning,
4722
+ ...devOnly ? {
4723
+ devScripts: {
4724
+ written: filesWritten.includes("package.json"),
4725
+ scripts: filesWritten.includes("package.json") ? Object.fromEntries(
4726
+ roles.map((r) => {
4727
+ const k = deriveDevKey(r);
4728
+ return [`dev:${k}`, `MOCK_USER=${k} next dev`];
4729
+ })
4730
+ ) : {},
4731
+ ...filesWritten.includes("package.json") ? {} : {
4732
+ warning: "package.json not found \u2014 dev scripts were not written. Run the auth phase again after scaffolding creates package.json."
4733
+ }
4734
+ }
4735
+ } : {}
2755
4736
  })
2756
4737
  }
2757
4738
  ]
@@ -2762,35 +4743,38 @@ function registerAuthTools(server2) {
2762
4743
  "stackwright_pro_configure_auth",
2763
4744
  "Generate authentication middleware and configuration for a Next.js Stackwright application. Writes `middleware.ts` from a secure template, appends/updates the `auth:` section in `stackwright.yml`, and generates `.env.example` with required environment variables. \u26A0\uFE0F For CAC/PKI: generated `middleware.ts` carries a SECURITY REVIEW REQUIRED comment \u2014 certificate chain validation must be verified by a DoD security officer before production deployment. This is the ONLY approved path to generating `middleware.ts`. Never write TypeScript auth files directly.",
2764
4745
  {
2765
- method: z11.enum(["cac", "oidc", "oauth2", "none"]),
2766
- provider: z11.enum(["azure-ad", "okta", "ping", "cognito", "custom"]).optional(),
4746
+ method: z13.enum(["cac", "oidc", "oauth2", "none"]),
4747
+ provider: z13.enum(["azure-ad", "okta", "ping", "cognito", "custom"]).optional(),
2767
4748
  // CAC
2768
- cacCaBundle: z11.string().optional(),
2769
- cacEdipiLookup: z11.string().optional(),
2770
- cacOcspEndpoint: z11.string().optional(),
2771
- cacCertHeader: z11.string().optional(),
4749
+ cacCaBundle: z13.string().optional(),
4750
+ cacEdipiLookup: z13.string().optional(),
4751
+ cacOcspEndpoint: z13.string().optional(),
4752
+ cacCertHeader: z13.string().optional(),
2772
4753
  // OIDC
2773
- oidcDiscoveryUrl: z11.string().optional(),
2774
- oidcClientId: z11.string().optional(),
2775
- oidcClientSecret: z11.string().optional(),
2776
- oidcScopes: z11.string().optional(),
2777
- oidcRoleClaim: z11.string().optional(),
4754
+ oidcDiscoveryUrl: z13.string().optional(),
4755
+ oidcClientId: z13.string().optional(),
4756
+ oidcClientSecret: z13.string().optional(),
4757
+ oidcScopes: z13.string().optional(),
4758
+ oidcRoleClaim: z13.string().optional(),
2778
4759
  // OAuth2
2779
- oauth2AuthUrl: z11.string().optional(),
2780
- oauth2TokenUrl: z11.string().optional(),
2781
- oauth2ClientId: z11.string().optional(),
2782
- oauth2ClientSecret: z11.string().optional(),
2783
- oauth2Scopes: z11.string().optional(),
4760
+ oauth2AuthUrl: z13.string().optional(),
4761
+ oauth2TokenUrl: z13.string().optional(),
4762
+ oauth2ClientId: z13.string().optional(),
4763
+ oauth2ClientSecret: z13.string().optional(),
4764
+ oauth2Scopes: z13.string().optional(),
2784
4765
  // RBAC
2785
- rbacRoles: z11.array(z11.string()).optional(),
2786
- rbacDefaultRole: z11.string().optional(),
4766
+ rbacRoles: jsonCoerce(z13.array(z13.string()).optional()),
4767
+ rbacDefaultRole: z13.string().optional(),
2787
4768
  // Audit
2788
- auditEnabled: z11.boolean().optional(),
2789
- auditRetentionDays: z11.number().int().positive().optional(),
4769
+ auditEnabled: boolCoerce(z13.boolean().optional()),
4770
+ auditRetentionDays: numCoerce(z13.number().int().positive().optional()),
2790
4771
  // Routes
2791
- protectedRoutes: z11.array(z11.string()).optional(),
4772
+ protectedRoutes: jsonCoerce(z13.array(z13.string()).optional()),
2792
4773
  // Injection for tests
2793
- _cwd: z11.string().optional()
4774
+ _cwd: z13.string().optional(),
4775
+ devOnly: boolCoerce(z13.boolean().optional()),
4776
+ mockUsers: jsonCoerce(z13.array(z13.object({ name: z13.string(), email: z13.string() })).optional()),
4777
+ nextMajorVersion: numCoerce(z13.number().int().positive().optional())
2794
4778
  },
2795
4779
  async (params) => {
2796
4780
  const cwd = params._cwd ?? process.cwd();
@@ -2800,45 +4784,61 @@ function registerAuthTools(server2) {
2800
4784
  }
2801
4785
 
2802
4786
  // src/integrity.ts
2803
- import { createHash as createHash2, timingSafeEqual } from "crypto";
2804
- import { readFileSync as readFileSync5, readdirSync, lstatSync as lstatSync5 } from "fs";
2805
- import { join as join6, basename } from "path";
4787
+ import { createHash as createHash4, timingSafeEqual as timingSafeEqual2 } from "crypto";
4788
+ import { readFileSync as readFileSync7, readdirSync as readdirSync2, lstatSync as lstatSync7 } from "fs";
4789
+ import { join as join7, basename } from "path";
2806
4790
  var _checksums = /* @__PURE__ */ new Map([
2807
4791
  [
2808
4792
  "stackwright-pro-api-otter.json",
2809
- "f1cc9edf2dd1df3ebcea1d0ab33d17a358faaf8aa97ee232cd7994042f2eac0d"
4793
+ "c21f127b9bed477b1d7b66949926e00e1b93de081e792293ebfffc88b70d9424"
2810
4794
  ],
2811
4795
  [
2812
4796
  "stackwright-pro-auth-otter.json",
2813
- "a19e06c503209a8a35fe321d30448623545b36b48c47a6ec064d13406ad1f725"
4797
+ "6d4d4d50f52be84a796c2a43ed997f00b41b42ed8c5f046e1defb2e423933588"
2814
4798
  ],
2815
4799
  [
2816
4800
  "stackwright-pro-dashboard-otter.json",
2817
- "b3cb3d7554f2e9eed3b57d5e0e3bf85d6ba5b4db5d3af5514391cf0575fcc001"
4801
+ "9c319d311801730e8dc9bc142eebb8fc5a7f48da48fa0b8d8c3b7431652447be"
2818
4802
  ],
2819
4803
  [
2820
4804
  "stackwright-pro-data-otter.json",
2821
- "bfacb87ae82867472a75982215554336a105a658d6cd3dd2c8b819fa1e11d7ac"
4805
+ "4d9369277685a4acc484116920c9622ad8a1838012a493fcfe42a6ae5abe53cf"
2822
4806
  ],
2823
4807
  [
2824
4808
  "stackwright-pro-designer-otter.json",
2825
- "c58fa7c7ead9e6398074e1c7ce3f31a8ef4eb3679f5fa18cc03cae3a87878c88"
4809
+ "af09ac8f06385bdbac63e2820daa2ff7d38b8ff1ff383c161f07e3fb9d9359c5"
4810
+ ],
4811
+ [
4812
+ "stackwright-pro-domain-expert-otter.json",
4813
+ "41e3a5838f05f6a51ed272860dd2d5b1df1f20bfc847eb8f39be109b89738e99"
2826
4814
  ],
2827
4815
  [
2828
4816
  "stackwright-pro-foreman-otter.json",
2829
- "d7f469661e26a36e280559626e0cf178c428664e3889cbd6b015ac1bab30715c"
4817
+ "e563a99d647fcf95401508f15f10e032fb14e826577bc6bcff5960bf2981d58d"
4818
+ ],
4819
+ [
4820
+ "stackwright-pro-geo-otter.json",
4821
+ "ff1191af8108cc70c09afe6a99b008dcb46f71e20f884da1ee424e431868a1b6"
2830
4822
  ],
2831
4823
  [
2832
4824
  "stackwright-pro-page-otter.json",
2833
- "65bec3a3a0dda6b7591bba2de9399f1e3a4fb99cfe1075342f4f4be98d917b67"
4825
+ "cdd878857c1d809c60263693ab0c6cbb45087fd9c3eeda5b4628bd7026d2bded"
4826
+ ],
4827
+ [
4828
+ "stackwright-pro-polish-otter.json",
4829
+ "e5f88d054dd536646f48e5d47cbd64f825f493100002309b90c912fa69ef279e"
2834
4830
  ],
2835
4831
  [
2836
4832
  "stackwright-pro-theme-otter.json",
2837
- "64ffaeeceacd739922788a1d074f6feaffc3f91d09706c2c104f0c0281677732"
4833
+ "532d9ca7db6911a09ce5029a8c74c89360bdf6cb8ede8c2636866b509cbe06f9"
2838
4834
  ],
2839
4835
  [
2840
4836
  "stackwright-pro-workflow-otter.json",
2841
- "0eec9d6a731678cf547c2a7b0b6fc338ca143c35501365a1e4e5dd2779dd5510"
4837
+ "e02c1a5f492dd6b11f68e293d92f5f661dbb22e57570dcf133a9d0ffc7a8be7d"
4838
+ ],
4839
+ [
4840
+ "stackwright-services-otter.json",
4841
+ "b8a252d6a2c5090138899925d3ea95bf3339b6e49adc276a1f5f7304b4ae5134"
2842
4842
  ]
2843
4843
  ]);
2844
4844
  Object.freeze(_checksums);
@@ -2853,11 +4853,11 @@ for (const [name, digest] of CANONICAL_CHECKSUMS) {
2853
4853
  }
2854
4854
  var MAX_OTTER_BYTES = 1 * 1024 * 1024;
2855
4855
  function computeSha256(data) {
2856
- return createHash2("sha256").update(data).digest("hex");
4856
+ return createHash4("sha256").update(data).digest("hex");
2857
4857
  }
2858
4858
  function safeEqual(a, b) {
2859
4859
  if (a.length !== b.length) return false;
2860
- return timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
4860
+ return timingSafeEqual2(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
2861
4861
  }
2862
4862
  function verifyOtterFile(filePath) {
2863
4863
  const filename = basename(filePath);
@@ -2867,7 +4867,7 @@ function verifyOtterFile(filePath) {
2867
4867
  }
2868
4868
  let stat;
2869
4869
  try {
2870
- stat = lstatSync5(filePath);
4870
+ stat = lstatSync7(filePath);
2871
4871
  } catch (err) {
2872
4872
  const msg = err instanceof Error ? err.message : String(err);
2873
4873
  return { verified: false, filename, error: `Cannot stat file: ${msg}` };
@@ -2885,7 +4885,7 @@ function verifyOtterFile(filePath) {
2885
4885
  }
2886
4886
  let raw;
2887
4887
  try {
2888
- raw = readFileSync5(filePath);
4888
+ raw = readFileSync7(filePath);
2889
4889
  } catch (err) {
2890
4890
  const msg = err instanceof Error ? err.message : String(err);
2891
4891
  return { verified: false, filename, error: `Cannot read file: ${msg}` };
@@ -2918,12 +4918,24 @@ function verifyOtterFile(filePath) {
2918
4918
  return { verified: true, filename };
2919
4919
  }
2920
4920
  function verifyAllOtters(otterDir) {
4921
+ if (/(?:^|[/\\])\.\.(?:[/\\]|$)/.test(otterDir) || otterDir.includes("..")) {
4922
+ return {
4923
+ verified: [],
4924
+ failed: [
4925
+ {
4926
+ filename: "<directory>",
4927
+ error: `Security: path traversal sequence detected in otter directory parameter`
4928
+ }
4929
+ ],
4930
+ unknown: []
4931
+ };
4932
+ }
2921
4933
  const verified = [];
2922
4934
  const failed = [];
2923
4935
  const unknown = [];
2924
4936
  let entries;
2925
4937
  try {
2926
- entries = readdirSync(otterDir);
4938
+ entries = readdirSync2(otterDir);
2927
4939
  } catch (err) {
2928
4940
  const msg = err instanceof Error ? err.message : String(err);
2929
4941
  return {
@@ -2934,9 +4946,9 @@ function verifyAllOtters(otterDir) {
2934
4946
  }
2935
4947
  const otterFiles = entries.filter((f) => f.endsWith("-otter.json"));
2936
4948
  for (const filename of otterFiles) {
2937
- const filePath = join6(otterDir, filename);
4949
+ const filePath = join7(otterDir, filename);
2938
4950
  try {
2939
- if (lstatSync5(filePath).isSymbolicLink()) {
4951
+ if (lstatSync7(filePath).isSymbolicLink()) {
2940
4952
  failed.push({ filename, error: "Skipped: symlink" });
2941
4953
  continue;
2942
4954
  }
@@ -2962,15 +4974,30 @@ var DEFAULT_SEARCH_PATHS = ["node_modules/@stackwright-pro/otters/src/", "packag
2962
4974
  function resolveOtterDir() {
2963
4975
  const cwd = process.cwd();
2964
4976
  for (const relative of DEFAULT_SEARCH_PATHS) {
2965
- const candidate = join6(cwd, relative);
4977
+ const candidate = join7(cwd, relative);
2966
4978
  try {
2967
- lstatSync5(candidate);
4979
+ lstatSync7(candidate);
2968
4980
  return candidate;
2969
4981
  } catch {
2970
4982
  }
2971
4983
  }
2972
4984
  return null;
2973
4985
  }
4986
+ function emitIntegrityAuditEvent(params) {
4987
+ const record = JSON.stringify({
4988
+ level: "AUDIT",
4989
+ event: "INTEGRITY_FAIL",
4990
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4991
+ source: "stackwright_pro_verify_otter_integrity",
4992
+ otterDir: params.otterDir,
4993
+ failedCount: params.failed.length,
4994
+ unknownCount: params.unknown.length,
4995
+ failures: params.failed,
4996
+ unknown: params.unknown
4997
+ });
4998
+ process.stderr.write(`INTEGRITY_FAIL ${record}
4999
+ `);
5000
+ }
2974
5001
  function registerIntegrityTools(server2) {
2975
5002
  server2.tool(
2976
5003
  "stackwright_pro_verify_otter_integrity",
@@ -2994,6 +5021,13 @@ function registerIntegrityTools(server2) {
2994
5021
  }
2995
5022
  const result = verifyAllOtters(resolved);
2996
5023
  const allGood = result.failed.length === 0 && result.unknown.length === 0;
5024
+ if (!allGood) {
5025
+ emitIntegrityAuditEvent({
5026
+ otterDir: resolved,
5027
+ failed: result.failed,
5028
+ unknown: result.unknown
5029
+ });
5030
+ }
2997
5031
  return {
2998
5032
  content: [
2999
5033
  {
@@ -3006,7 +5040,10 @@ function registerIntegrityTools(server2) {
3006
5040
  unknownCount: result.unknown.length,
3007
5041
  verified: result.verified,
3008
5042
  failed: result.failed,
3009
- unknown: result.unknown
5043
+ unknown: result.unknown,
5044
+ ...allGood ? {} : {
5045
+ error: "INTEGRITY CHECK FAILED: SHA-256 mismatch detected in otter agent definitions. Do not proceed \u2014 otter files may have been tampered with."
5046
+ }
3010
5047
  })
3011
5048
  }
3012
5049
  ],
@@ -3017,14 +5054,14 @@ function registerIntegrityTools(server2) {
3017
5054
  }
3018
5055
 
3019
5056
  // src/tools/domain.ts
3020
- import { z as z12 } from "zod";
3021
- import { readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
3022
- import { join as join7 } from "path";
5057
+ import { z as z14 } from "zod";
5058
+ import { readFileSync as readFileSync8, existsSync as existsSync8 } from "fs";
5059
+ import { join as join8 } from "path";
3023
5060
  function handleListCollections(input) {
3024
5061
  const cwd = input._cwd ?? process.cwd();
3025
5062
  const sources = [
3026
5063
  {
3027
- path: join7(cwd, ".stackwright", "artifacts", "data-config.json"),
5064
+ path: join8(cwd, ".stackwright", "artifacts", "data-config.json"),
3028
5065
  source: "data-config.json",
3029
5066
  parse: (raw) => {
3030
5067
  const parsed = JSON.parse(raw);
@@ -3035,15 +5072,15 @@ function handleListCollections(input) {
3035
5072
  }
3036
5073
  },
3037
5074
  {
3038
- path: join7(cwd, "stackwright.yml"),
5075
+ path: join8(cwd, "stackwright.yml"),
3039
5076
  source: "stackwright.yml",
3040
5077
  parse: extractCollectionsFromYaml
3041
5078
  }
3042
5079
  ];
3043
5080
  for (const { path: path3, source, parse } of sources) {
3044
- if (!existsSync6(path3)) continue;
5081
+ if (!existsSync8(path3)) continue;
3045
5082
  try {
3046
- const collections = parse(readFileSync6(path3, "utf8"));
5083
+ const collections = parse(readFileSync8(path3, "utf8"));
3047
5084
  return {
3048
5085
  text: JSON.stringify({ collections, source, collectionCount: collections.length }),
3049
5086
  isError: false
@@ -3194,8 +5231,8 @@ function handleValidateWorkflow(input) {
3194
5231
  if (input.workflow && Object.keys(input.workflow).length > 0) {
3195
5232
  raw = input.workflow;
3196
5233
  } else {
3197
- const artifactPath = join7(cwd, ".stackwright", "artifacts", "workflow-config.json");
3198
- if (!existsSync6(artifactPath)) {
5234
+ const artifactPath = join8(cwd, ".stackwright", "artifacts", "workflow-config.json");
5235
+ if (!existsSync8(artifactPath)) {
3199
5236
  return fail([
3200
5237
  {
3201
5238
  code: "NO_WORKFLOW",
@@ -3204,7 +5241,7 @@ function handleValidateWorkflow(input) {
3204
5241
  ]);
3205
5242
  }
3206
5243
  try {
3207
- raw = JSON.parse(readFileSync6(artifactPath, "utf8"));
5244
+ raw = JSON.parse(readFileSync8(artifactPath, "utf8"));
3208
5245
  } catch (err) {
3209
5246
  return fail([{ code: "INVALID_JSON", message: `Failed to parse workflow artifact: ${err}` }]);
3210
5247
  }
@@ -3403,7 +5440,7 @@ function registerDomainTools(server2) {
3403
5440
  "stackwright_pro_resolve_data_strategy",
3404
5441
  "Look up the data freshness strategy configuration from the user's answer. Returns mechanism, revalidation seconds, required packages, and the exact MCP tool call to make. Replaces the strategy table in the data-otter prompt.",
3405
5442
  {
3406
- strategy: z12.string().describe(
5443
+ strategy: z14.string().describe(
3407
5444
  'The data-1 answer value: "pulse-fast", "isr-fast", "isr-standard", or "isr-slow"'
3408
5445
  )
3409
5446
  },
@@ -3413,7 +5450,7 @@ function registerDomainTools(server2) {
3413
5450
  "stackwright_pro_validate_workflow",
3414
5451
  "Validate a workflow definition against the Stackwright workflow schema. Checks step ID uniqueness, transition targets, terminal state existence, and service references. Call this after the workflow otter produces output.",
3415
5452
  {
3416
- workflow: z12.record(z12.string(), z12.unknown()).optional().describe(
5453
+ workflow: jsonCoerce(z14.record(z14.string(), z14.unknown()).optional()).describe(
3417
5454
  "Parsed workflow object. If omitted, reads from .stackwright/artifacts/workflow-config.json"
3418
5455
  )
3419
5456
  },
@@ -3421,18 +5458,533 @@ function registerDomainTools(server2) {
3421
5458
  );
3422
5459
  }
3423
5460
 
5461
+ // src/tools/type-schemas.ts
5462
+ import { z as z15 } from "zod";
5463
+ function buildTypeSchemaSummary() {
5464
+ return {
5465
+ version: "1.0",
5466
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5467
+ domains: {
5468
+ workflow: {
5469
+ description: "Workflow DSL \u2014 step definitions, auth blocks, field types, conditions",
5470
+ schemas: [
5471
+ "WorkflowFileSchema",
5472
+ "WorkflowDefinitionSchema",
5473
+ "WorkflowStepSchema",
5474
+ "WorkflowStepTypeSchema",
5475
+ "WorkflowFieldSchema",
5476
+ "WorkflowActionSchema",
5477
+ "WorkflowAuthSchema",
5478
+ "WorkflowThemeSchema",
5479
+ "TransitionConditionSchema",
5480
+ "PersistenceSchema"
5481
+ ],
5482
+ otter: "stackwright-pro-workflow-otter",
5483
+ artifactKey: "workflowConfig"
5484
+ },
5485
+ auth: {
5486
+ description: "Authentication providers \u2014 PKI/CAC, OIDC, RBAC configuration",
5487
+ schemas: [
5488
+ "authConfigSchema",
5489
+ "pkiConfigSchema",
5490
+ "oidcConfigSchema",
5491
+ "rbacConfigSchema",
5492
+ "componentAuthSchema",
5493
+ "authUserSchema",
5494
+ "authSessionSchema"
5495
+ ],
5496
+ otter: "stackwright-pro-auth-otter",
5497
+ artifactKey: "authConfig"
5498
+ },
5499
+ openapi: {
5500
+ description: "OpenAPI spec integration \u2014 collection config, endpoint filters, actions",
5501
+ interfaces: [
5502
+ "OpenAPIConfig",
5503
+ "ActionConfig",
5504
+ "EndpointFilter",
5505
+ "ApprovedSpec",
5506
+ "PrebuildSecurityConfig",
5507
+ "SiteConfig",
5508
+ "ValidationResult"
5509
+ ],
5510
+ otter: "stackwright-pro-api-otter",
5511
+ artifactKey: "apiConfig"
5512
+ },
5513
+ pulse: {
5514
+ description: "Real-time data polling \u2014 source-agnostic polling options and states",
5515
+ interfaces: ["PulseOptions", "PulseMeta", "PulseState"],
5516
+ note: "React-bound types (PulseProps, PulseIndicatorProps) remain in @stackwright-pro/pulse"
5517
+ },
5518
+ enterprise: {
5519
+ description: "Enterprise collection access \u2014 multi-tenant provider extension",
5520
+ interfaces: [
5521
+ "EnterpriseCollectionProvider",
5522
+ "CollectionProvider",
5523
+ "CollectionEntry",
5524
+ "CollectionListOptions",
5525
+ "CollectionListResult",
5526
+ "TenantFilter",
5527
+ "Collection"
5528
+ ],
5529
+ note: "CollectionProvider, CollectionEntry, CollectionListOptions, and CollectionListResult are re-exported from @stackwright/types (^1.5.0). EnterpriseCollectionProvider, TenantFilter, and Collection are Pro-only extensions."
5530
+ }
5531
+ }
5532
+ };
5533
+ }
5534
+ function registerTypeSchemasTool(server2) {
5535
+ server2.tool(
5536
+ "stackwright_pro_get_type_schemas",
5537
+ "Returns a structured summary of all canonical @stackwright-pro/types schemas, organized by domain. Use this to determine which otter owns a given schema and what artifact key to expect.",
5538
+ {
5539
+ format: z15.enum(["full", "domains-only"]).optional().default("full").describe("full = complete summary with all fields; domains-only = just domain names")
5540
+ },
5541
+ async ({ format }) => {
5542
+ const summary = buildTypeSchemaSummary();
5543
+ const output = format === "domains-only" ? Object.keys(summary.domains) : summary;
5544
+ return {
5545
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
5546
+ };
5547
+ }
5548
+ );
5549
+ }
5550
+
5551
+ // src/tools/scaffold-cleanup.ts
5552
+ import {
5553
+ existsSync as existsSync9,
5554
+ lstatSync as lstatSync8,
5555
+ unlinkSync as unlinkSync2,
5556
+ readFileSync as readFileSync9,
5557
+ writeFileSync as writeFileSync7,
5558
+ readdirSync as readdirSync3,
5559
+ rmdirSync,
5560
+ mkdirSync as mkdirSync7
5561
+ } from "fs";
5562
+ import { join as join9, dirname as dirname2 } from "path";
5563
+ var SCAFFOLD_FILES_TO_DELETE = [
5564
+ "content/posts/getting-started.yaml",
5565
+ "content/posts/hello-world.yaml"
5566
+ ];
5567
+ var SCAFFOLD_DIRS_TO_PRUNE = [
5568
+ "content/posts"
5569
+ // Don't prune 'content' — user may have other content directories
5570
+ ];
5571
+ var NOT_FOUND_PATH = "app/not-found.tsx";
5572
+ var FALLBACK_COLORS = {
5573
+ background: "#ffffff",
5574
+ foreground: "#171717",
5575
+ primary: "#525252",
5576
+ mutedForeground: "#737373"
5577
+ };
5578
+ function readThemeColors(cwd) {
5579
+ const tokenPath = join9(cwd, ".stackwright", "artifacts", "theme-tokens.json");
5580
+ if (!existsSync9(tokenPath)) return { ...FALLBACK_COLORS };
5581
+ const stat = lstatSync8(tokenPath);
5582
+ if (stat.isSymbolicLink()) return { ...FALLBACK_COLORS };
5583
+ try {
5584
+ const raw = JSON.parse(readFileSync9(tokenPath, "utf-8"));
5585
+ const css = raw?.cssVariables ?? {};
5586
+ const toHsl = (hslValue) => {
5587
+ if (!hslValue || typeof hslValue !== "string") return null;
5588
+ return `hsl(${hslValue})`;
5589
+ };
5590
+ return {
5591
+ background: toHsl(css["--background"]) ?? FALLBACK_COLORS.background,
5592
+ foreground: toHsl(css["--foreground"]) ?? FALLBACK_COLORS.foreground,
5593
+ primary: toHsl(css["--primary"]) ?? FALLBACK_COLORS.primary,
5594
+ mutedForeground: toHsl(css["--muted-foreground"]) ?? FALLBACK_COLORS.mutedForeground
5595
+ };
5596
+ } catch {
5597
+ return { ...FALLBACK_COLORS };
5598
+ }
5599
+ }
5600
+ function generateNotFoundPage(colors) {
5601
+ return `export default function NotFound() {
5602
+ return (
5603
+ <div
5604
+ style={{
5605
+ display: 'flex',
5606
+ flexDirection: 'column',
5607
+ alignItems: 'center',
5608
+ justifyContent: 'center',
5609
+ minHeight: '100vh',
5610
+ backgroundColor: '${colors.background}',
5611
+ color: '${colors.foreground}',
5612
+ fontFamily: 'system-ui, -apple-system, sans-serif',
5613
+ padding: '2rem',
5614
+ textAlign: 'center',
5615
+ }}
5616
+ >
5617
+ <h1 style={{ fontSize: '4rem', fontWeight: 700, margin: 0, color: '${colors.primary}' }}>
5618
+ 404
5619
+ </h1>
5620
+ <p style={{ fontSize: '1.25rem', color: '${colors.mutedForeground}', marginTop: '0.5rem' }}>
5621
+ Page not found
5622
+ </p>
5623
+ <a
5624
+ href="/"
5625
+ style={{
5626
+ marginTop: '2rem',
5627
+ padding: '0.75rem 1.5rem',
5628
+ backgroundColor: '${colors.primary}',
5629
+ color: '${colors.background}',
5630
+ borderRadius: '0.5rem',
5631
+ textDecoration: 'none',
5632
+ fontSize: '0.875rem',
5633
+ fontWeight: 500,
5634
+ }}
5635
+ >
5636
+ Go Home
5637
+ </a>
5638
+ </div>
5639
+ );
5640
+ }
5641
+ `;
5642
+ }
5643
+ function isSafePath(fullPath) {
5644
+ if (!existsSync9(fullPath)) return true;
5645
+ const stat = lstatSync8(fullPath);
5646
+ return !stat.isSymbolicLink();
5647
+ }
5648
+ function isDirEmpty(dirPath) {
5649
+ if (!existsSync9(dirPath)) return false;
5650
+ const stat = lstatSync8(dirPath);
5651
+ if (!stat.isDirectory()) return false;
5652
+ return readdirSync3(dirPath).length === 0;
5653
+ }
5654
+ function handleCleanupScaffold(_cwd) {
5655
+ const cwd = _cwd ?? process.cwd();
5656
+ const result = {
5657
+ deleted: [],
5658
+ rewritten: [],
5659
+ skipped: [],
5660
+ errors: [],
5661
+ prunedDirs: []
5662
+ };
5663
+ for (const relPath of SCAFFOLD_FILES_TO_DELETE) {
5664
+ const fullPath = join9(cwd, relPath);
5665
+ if (!existsSync9(fullPath)) {
5666
+ result.skipped.push(relPath);
5667
+ continue;
5668
+ }
5669
+ if (!isSafePath(fullPath)) {
5670
+ result.errors.push(`Refusing to delete symlink: ${relPath}`);
5671
+ continue;
5672
+ }
5673
+ try {
5674
+ unlinkSync2(fullPath);
5675
+ result.deleted.push(relPath);
5676
+ } catch (err) {
5677
+ result.errors.push(`Failed to delete ${relPath}: ${String(err)}`);
5678
+ }
5679
+ }
5680
+ for (const relDir of SCAFFOLD_DIRS_TO_PRUNE) {
5681
+ const fullDir = join9(cwd, relDir);
5682
+ if (!existsSync9(fullDir)) continue;
5683
+ if (!isSafePath(fullDir)) {
5684
+ result.errors.push(`Refusing to prune symlink directory: ${relDir}`);
5685
+ continue;
5686
+ }
5687
+ if (isDirEmpty(fullDir)) {
5688
+ try {
5689
+ rmdirSync(fullDir);
5690
+ result.prunedDirs.push(relDir);
5691
+ } catch (err) {
5692
+ result.errors.push(`Failed to prune directory ${relDir}: ${String(err)}`);
5693
+ }
5694
+ }
5695
+ }
5696
+ {
5697
+ const fullPath = join9(cwd, NOT_FOUND_PATH);
5698
+ if (!existsSync9(fullPath)) {
5699
+ result.skipped.push(NOT_FOUND_PATH);
5700
+ } else if (!isSafePath(fullPath)) {
5701
+ result.errors.push(`Refusing to rewrite symlink: ${NOT_FOUND_PATH}`);
5702
+ } else {
5703
+ try {
5704
+ const colors = readThemeColors(cwd);
5705
+ const content = generateNotFoundPage(colors);
5706
+ const dir = dirname2(fullPath);
5707
+ mkdirSync7(dir, { recursive: true });
5708
+ writeFileSync7(fullPath, content, "utf-8");
5709
+ result.rewritten.push(NOT_FOUND_PATH);
5710
+ } catch (err) {
5711
+ result.errors.push(`Failed to rewrite ${NOT_FOUND_PATH}: ${String(err)}`);
5712
+ }
5713
+ }
5714
+ }
5715
+ return {
5716
+ text: JSON.stringify(result),
5717
+ isError: false
5718
+ };
5719
+ }
5720
+ function registerScaffoldCleanupTools(server2) {
5721
+ const DESC = "Deterministic scaffold remnant cleanup \u2014 removes stale OSS scaffold files after raft run.";
5722
+ server2.tool(
5723
+ "stackwright_pro_cleanup_scaffold",
5724
+ `Remove/rewrite stale scaffold files (blog posts, hardcoded not-found page) after the raft pipeline completes. ${DESC}`,
5725
+ {},
5726
+ async () => {
5727
+ const r = handleCleanupScaffold();
5728
+ return {
5729
+ content: [{ type: "text", text: r.text }],
5730
+ isError: r.isError
5731
+ };
5732
+ }
5733
+ );
5734
+ }
5735
+
5736
+ // src/tools/contrast.ts
5737
+ import { z as z16 } from "zod";
5738
+ function linearizeChannel(c255) {
5739
+ const c = c255 / 255;
5740
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
5741
+ }
5742
+ function relativeLuminance(r, g, b) {
5743
+ return 0.2126 * linearizeChannel(r) + 0.7152 * linearizeChannel(g) + 0.0722 * linearizeChannel(b);
5744
+ }
5745
+ function contrastRatioFromLuminance(l1, l2) {
5746
+ const lighter = Math.max(l1, l2);
5747
+ const darker = Math.min(l1, l2);
5748
+ return (lighter + 0.05) / (darker + 0.05);
5749
+ }
5750
+ function parseHex(hex) {
5751
+ const clean = hex.replace("#", "").trim().toLowerCase();
5752
+ if (clean.length === 3) {
5753
+ const r = parseInt(clean.charAt(0) + clean.charAt(0), 16);
5754
+ const g = parseInt(clean.charAt(1) + clean.charAt(1), 16);
5755
+ const b = parseInt(clean.charAt(2) + clean.charAt(2), 16);
5756
+ if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
5757
+ return [r, g, b];
5758
+ }
5759
+ if (clean.length === 6) {
5760
+ const r = parseInt(clean.slice(0, 2), 16);
5761
+ const g = parseInt(clean.slice(2, 4), 16);
5762
+ const b = parseInt(clean.slice(4, 6), 16);
5763
+ if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
5764
+ return [r, g, b];
5765
+ }
5766
+ return null;
5767
+ }
5768
+ function hslToRgb(h, s, l) {
5769
+ const hn = h / 360;
5770
+ const sn = s / 100;
5771
+ const ln = l / 100;
5772
+ const hue2rgb = (p, q, t) => {
5773
+ let tt = t;
5774
+ if (tt < 0) tt += 1;
5775
+ if (tt > 1) tt -= 1;
5776
+ if (tt < 1 / 6) return p + (q - p) * 6 * tt;
5777
+ if (tt < 1 / 2) return q;
5778
+ if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
5779
+ return p;
5780
+ };
5781
+ let r, g, b;
5782
+ if (sn === 0) {
5783
+ r = g = b = ln;
5784
+ } else {
5785
+ const q = ln < 0.5 ? ln * (1 + sn) : ln + sn - ln * sn;
5786
+ const p = 2 * ln - q;
5787
+ r = hue2rgb(p, q, hn + 1 / 3);
5788
+ g = hue2rgb(p, q, hn);
5789
+ b = hue2rgb(p, q, hn - 1 / 3);
5790
+ }
5791
+ return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
5792
+ }
5793
+ function parseHsl(hsl) {
5794
+ let str = hsl.replace(/^hsl\s*\(\s*/i, "").replace(/\s*\)$/, "").trim();
5795
+ str = str.replace(/%/g, "");
5796
+ const parts = str.split(/[\s,]+/).filter(Boolean);
5797
+ if (parts.length !== 3) return null;
5798
+ const h = parseFloat(parts[0]);
5799
+ const s = parseFloat(parts[1]);
5800
+ const l = parseFloat(parts[2]);
5801
+ if (isNaN(h) || isNaN(s) || isNaN(l)) return null;
5802
+ return hslToRgb(h, s, l);
5803
+ }
5804
+ function parseColor(color) {
5805
+ const trimmed = color.trim();
5806
+ if (trimmed.startsWith("#")) return parseHex(trimmed);
5807
+ if (/^hsl\s*\(/i.test(trimmed)) return parseHsl(trimmed);
5808
+ if (/^\d[\d.]*[\s,]+\d[\d.]*%?[\s,]+\d[\d.]*%?$/.test(trimmed)) return parseHsl(trimmed);
5809
+ return null;
5810
+ }
5811
+ function rgbToHex(r, g, b) {
5812
+ return "#" + [r, g, b].map(
5813
+ (c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, "0")
5814
+ ).join("");
5815
+ }
5816
+ function handleCheckContrast(fg, bg) {
5817
+ const fgRgb = parseColor(fg);
5818
+ const bgRgb = parseColor(bg);
5819
+ if (!fgRgb) throw new Error(`Cannot parse foreground color: "${fg}"`);
5820
+ if (!bgRgb) throw new Error(`Cannot parse background color: "${bg}"`);
5821
+ const fgL = relativeLuminance(...fgRgb);
5822
+ const bgL = relativeLuminance(...bgRgb);
5823
+ const ratio = parseFloat(contrastRatioFromLuminance(fgL, bgL).toFixed(2));
5824
+ return {
5825
+ fgHex: rgbToHex(...fgRgb),
5826
+ bgHex: rgbToHex(...bgRgb),
5827
+ ratio,
5828
+ aa: ratio >= 4.5,
5829
+ aaLarge: ratio >= 3,
5830
+ aaa: ratio >= 7,
5831
+ aaaLarge: ratio >= 4.5
5832
+ };
5833
+ }
5834
+ function handleDeriveAccessiblePalette(seed, targetRatio) {
5835
+ const seedRgb = parseColor(seed);
5836
+ if (!seedRgb) throw new Error(`Cannot parse seed color: "${seed}"`);
5837
+ const seedHex = rgbToHex(...seedRgb);
5838
+ const seedL = relativeLuminance(...seedRgb);
5839
+ const whiteRatio = parseFloat(contrastRatioFromLuminance(1, seedL).toFixed(2));
5840
+ const blackRatio = parseFloat(contrastRatioFromLuminance(0, seedL).toFixed(2));
5841
+ const whiteWorks = whiteRatio >= targetRatio;
5842
+ const blackWorks = blackRatio >= targetRatio;
5843
+ let strategy;
5844
+ let foreground;
5845
+ let ratio;
5846
+ let alternativeForeground;
5847
+ let alternativeRatio;
5848
+ if (blackWorks && whiteWorks) {
5849
+ if (blackRatio >= whiteRatio) {
5850
+ strategy = "black";
5851
+ foreground = "#000000";
5852
+ ratio = blackRatio;
5853
+ alternativeForeground = "#ffffff";
5854
+ alternativeRatio = whiteRatio;
5855
+ } else {
5856
+ strategy = "white";
5857
+ foreground = "#ffffff";
5858
+ ratio = whiteRatio;
5859
+ alternativeForeground = "#000000";
5860
+ alternativeRatio = blackRatio;
5861
+ }
5862
+ } else if (blackWorks) {
5863
+ strategy = "black";
5864
+ foreground = "#000000";
5865
+ ratio = blackRatio;
5866
+ alternativeForeground = "#ffffff";
5867
+ alternativeRatio = whiteRatio;
5868
+ } else if (whiteWorks) {
5869
+ strategy = "white";
5870
+ foreground = "#ffffff";
5871
+ ratio = whiteRatio;
5872
+ alternativeForeground = "#000000";
5873
+ alternativeRatio = blackRatio;
5874
+ } else {
5875
+ strategy = "unachievable";
5876
+ if (blackRatio >= whiteRatio) {
5877
+ foreground = "#000000";
5878
+ ratio = blackRatio;
5879
+ alternativeForeground = "#ffffff";
5880
+ alternativeRatio = whiteRatio;
5881
+ } else {
5882
+ foreground = "#ffffff";
5883
+ ratio = whiteRatio;
5884
+ alternativeForeground = "#000000";
5885
+ alternativeRatio = blackRatio;
5886
+ }
5887
+ }
5888
+ return {
5889
+ seedHex,
5890
+ foreground,
5891
+ ratio,
5892
+ aa: ratio >= 4.5,
5893
+ aaLarge: ratio >= 3,
5894
+ aaa: ratio >= 7,
5895
+ meetsTarget: ratio >= targetRatio,
5896
+ strategy,
5897
+ alternativeForeground,
5898
+ alternativeRatio
5899
+ };
5900
+ }
5901
+ var PASS = "[PASS]";
5902
+ var FAIL = "[FAIL]";
5903
+ var mark = (ok) => ok ? PASS : FAIL;
5904
+ function registerContrastTools(server2) {
5905
+ server2.tool(
5906
+ "stackwright_pro_check_contrast",
5907
+ 'Compute the exact WCAG 2.1 contrast ratio between a foreground and background color. Returns the ratio and AA/AAA pass/fail flags. Use this to verify any foreground/background color pair before writing it to the token set. Accepts #RGB, #RRGGBB, hsl(...), or shadcn HSL format ("H S% L%").',
5908
+ {
5909
+ fg: z16.string().describe("Foreground (text) color \u2014 hex or HSL"),
5910
+ bg: z16.string().describe("Background color \u2014 hex or HSL")
5911
+ },
5912
+ async ({ fg, bg }) => {
5913
+ let result;
5914
+ try {
5915
+ result = handleCheckContrast(fg, bg);
5916
+ } catch (err) {
5917
+ return {
5918
+ content: [{ type: "text", text: `Error: ${err.message}` }]
5919
+ };
5920
+ }
5921
+ const text = [
5922
+ `WCAG 2.1 Contrast Check`,
5923
+ ``,
5924
+ ` Foreground : ${result.fgHex}`,
5925
+ ` Background : ${result.bgHex}`,
5926
+ ` Ratio : ${result.ratio}:1`,
5927
+ ``,
5928
+ ` ${mark(result.aa)} AA \u2014 normal text (\u22654.5:1)`,
5929
+ ` ${mark(result.aaLarge)} AA \u2014 large text (\u22653.0:1)`,
5930
+ ` ${mark(result.aaa)} AAA \u2014 normal text (\u22657.0:1)`,
5931
+ ` ${mark(result.aaaLarge)} AAA \u2014 large text (\u22654.5:1)`
5932
+ ].join("\n");
5933
+ return { content: [{ type: "text", text }] };
5934
+ }
5935
+ );
5936
+ server2.tool(
5937
+ "stackwright_pro_derive_accessible_palette",
5938
+ 'Given a seed color (treated as a background), derive the best accessible foreground (text) color that meets the target contrast ratio. Evaluates #ffffff and #000000 against the seed and picks whichever satisfies the target ratio with the highest contrast. Use this for every {name}-foreground token derivation instead of guessing white vs black. Accepts #RGB, #RRGGBB, hsl(...), or shadcn HSL format ("H S% L%").',
5939
+ {
5940
+ seed: z16.string().describe("Background (seed) color \u2014 hex or HSL"),
5941
+ targetRatio: numCoerce(z16.number().default(4.5)).describe(
5942
+ "Minimum required contrast ratio (default 4.5 for WCAG AA)"
5943
+ )
5944
+ },
5945
+ async ({ seed, targetRatio }) => {
5946
+ let result;
5947
+ try {
5948
+ result = handleDeriveAccessiblePalette(seed, targetRatio ?? 4.5);
5949
+ } catch (err) {
5950
+ return {
5951
+ content: [{ type: "text", text: `Error: ${err.message}` }]
5952
+ };
5953
+ }
5954
+ const metLine = result.meetsTarget ? `${PASS} Meets target (${targetRatio}:1)` : `${FAIL} Target (${targetRatio}:1) UNACHIEVABLE with white or black \u2014 best available used`;
5955
+ const altNote = result.alternativeRatio >= (targetRatio ?? 4.5) ? `also passes (${result.alternativeRatio}:1)` : `fails at ${result.alternativeRatio}:1`;
5956
+ const text = [
5957
+ `Accessible Palette Derivation`,
5958
+ ``,
5959
+ ` Background (seed) : ${result.seedHex}`,
5960
+ ` Foreground : ${result.foreground} (${result.strategy})`,
5961
+ ` Contrast ratio : ${result.ratio}:1`,
5962
+ ` ${metLine}`,
5963
+ ``,
5964
+ ` ${mark(result.aa)} AA \u2014 normal text (\u22654.5:1)`,
5965
+ ` ${mark(result.aaLarge)} AA \u2014 large text (\u22653.0:1)`,
5966
+ ` ${mark(result.aaa)} AAA \u2014 normal text (\u22657.0:1)`,
5967
+ ``,
5968
+ ` Alternative (${result.alternativeForeground}): ${altNote}`
5969
+ ].join("\n");
5970
+ return { content: [{ type: "text", text }] };
5971
+ }
5972
+ );
5973
+ }
5974
+
3424
5975
  // package.json
3425
5976
  var package_default = {
3426
5977
  dependencies: {
5978
+ "@stackwright-pro/types": "workspace:*",
3427
5979
  "@modelcontextprotocol/sdk": "^1.10.0",
3428
5980
  "@stackwright-pro/cli-data-explorer": "workspace:*",
3429
- zod: "^4.3.6"
5981
+ zod: "^4.4.3"
3430
5982
  },
3431
5983
  devDependencies: {
3432
- "@types/node": "^24.1.0",
3433
- tsup: "^8.5.0",
3434
- typescript: "^5.8.3",
3435
- vitest: "^4.0.18"
5984
+ "@types/node": "catalog:",
5985
+ tsup: "catalog:",
5986
+ typescript: "catalog:",
5987
+ vitest: "catalog:"
3436
5988
  },
3437
5989
  scripts: {
3438
5990
  prepublishOnly: "node scripts/verify-integrity-sync.js",
@@ -3443,10 +5995,13 @@ var package_default = {
3443
5995
  "test:coverage": "vitest run --coverage"
3444
5996
  },
3445
5997
  name: "@stackwright-pro/mcp",
3446
- version: "0.2.0-alpha.7",
5998
+ version: "0.2.0-alpha.71",
3447
5999
  description: "MCP tools for Stackwright Pro - Data Explorer, Security, ISR, and Dashboard generation",
3448
- license: "PROPRIETARY",
6000
+ license: "SEE LICENSE IN LICENSE",
3449
6001
  main: "./dist/server.js",
6002
+ bin: {
6003
+ "stackwright-pro-mcp": "./dist/server.js"
6004
+ },
3450
6005
  module: "./dist/server.mjs",
3451
6006
  types: "./dist/server.d.ts",
3452
6007
  exports: {
@@ -3459,6 +6014,11 @@ var package_default = {
3459
6014
  types: "./dist/integrity.d.ts",
3460
6015
  import: "./dist/integrity.mjs",
3461
6016
  require: "./dist/integrity.js"
6017
+ },
6018
+ "./type-schemas": {
6019
+ types: "./dist/tools/type-schemas.d.ts",
6020
+ import: "./dist/tools/type-schemas.mjs",
6021
+ require: "./dist/tools/type-schemas.js"
3462
6022
  }
3463
6023
  },
3464
6024
  files: [
@@ -3486,9 +6046,22 @@ registerPipelineTools(server);
3486
6046
  registerSafeWriteTools(server);
3487
6047
  registerAuthTools(server);
3488
6048
  registerIntegrityTools(server);
6049
+ registerArtifactSigningTools(server);
3489
6050
  registerDomainTools(server);
6051
+ registerTypeSchemasTool(server);
6052
+ registerScaffoldCleanupTools(server);
6053
+ registerContrastTools(server);
3490
6054
  async function main() {
3491
6055
  const transport = new StdioServerTransport();
6056
+ try {
6057
+ const servicesRegisterPkg = "@stackwright-services/mcp/register";
6058
+ const mod = await import(servicesRegisterPkg);
6059
+ if (typeof mod.registerServicesTools === "function") {
6060
+ mod.registerServicesTools(server);
6061
+ console.error("Stackwright Services tools registered on pro MCP");
6062
+ }
6063
+ } catch {
6064
+ }
3492
6065
  await server.connect(transport);
3493
6066
  console.error("Stackwright Pro MCP server running on stdio");
3494
6067
  }