@stackwright-pro/mcp 0.2.0-alpha.6 → 0.2.0-alpha.61
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/LICENSE +21 -0
- package/dist/integrity.d.mts +29 -1
- package/dist/integrity.d.ts +29 -1
- package/dist/integrity.js +65 -10
- package/dist/integrity.js.map +1 -1
- package/dist/integrity.mjs +64 -10
- package/dist/integrity.mjs.map +1 -1
- package/dist/server.js +1690 -324
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +1707 -325
- package/dist/server.mjs.map +1 -1
- package/dist/tools/type-schemas.d.mts +51 -0
- package/dist/tools/type-schemas.d.ts +51 -0
- package/dist/tools/type-schemas.js +120 -0
- package/dist/tools/type-schemas.js.map +1 -0
- package/dist/tools/type-schemas.mjs +94 -0
- package/dist/tools/type-schemas.mjs.map +1 -0
- package/package.json +17 -8
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:
|
|
14
|
-
projectRoot:
|
|
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:
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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:
|
|
130
|
-
configPath:
|
|
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:
|
|
239
|
-
url:
|
|
240
|
-
configPath:
|
|
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:
|
|
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
|
|
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:
|
|
370
|
-
revalidateSeconds:
|
|
371
|
-
|
|
372
|
-
|
|
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:
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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:
|
|
422
|
-
configPath:
|
|
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
|
|
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:
|
|
458
|
-
layout:
|
|
459
|
-
pageTitle:
|
|
460
|
-
specPath:
|
|
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:
|
|
547
|
-
slugField:
|
|
548
|
-
specPath:
|
|
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
|
-
" -
|
|
572
|
-
'
|
|
573
|
-
"
|
|
574
|
-
`
|
|
575
|
-
'
|
|
576
|
-
"
|
|
577
|
-
`
|
|
578
|
-
'
|
|
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
|
-
'
|
|
584
|
-
"
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
yamlLines.push(
|
|
597
|
-
yamlLines.push(
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
|
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:
|
|
713
|
-
question_type:
|
|
714
|
-
question:
|
|
715
|
-
choices:
|
|
716
|
-
|
|
717
|
-
|
|
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:
|
|
747
|
-
selected_values:
|
|
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
|
|
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:
|
|
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."
|
|
868
|
+
),
|
|
869
|
+
scripts: jsonCoerce(z7.record(z7.string(), z7.string()).optional()).describe(
|
|
870
|
+
"npm scripts to add. Only adds if key does not already exist."
|
|
815
871
|
),
|
|
816
|
-
|
|
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(
|
|
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:
|
|
822
|
-
|
|
823
|
-
|
|
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 =
|
|
1037
|
+
const PackageJsonSchema = z7.object({
|
|
982
1038
|
// Zod v4: z.record(keySchema, valueSchema) — two-arg form required
|
|
983
|
-
dependencies:
|
|
984
|
-
devDependencies:
|
|
985
|
-
scripts:
|
|
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
|
|
1125
|
+
import { z as z8 } from "zod";
|
|
1069
1126
|
|
|
1070
1127
|
// src/question-adapter.ts
|
|
1071
1128
|
function truncate(str, maxLength) {
|
|
@@ -1251,22 +1308,22 @@ function answersToManifestFormat(answers, questions) {
|
|
|
1251
1308
|
}
|
|
1252
1309
|
|
|
1253
1310
|
// src/tools/questions.ts
|
|
1254
|
-
var ManifestQuestionSchema =
|
|
1255
|
-
id:
|
|
1256
|
-
question:
|
|
1257
|
-
type:
|
|
1258
|
-
required:
|
|
1259
|
-
options:
|
|
1260
|
-
|
|
1261
|
-
label:
|
|
1262
|
-
value:
|
|
1311
|
+
var ManifestQuestionSchema = z8.object({
|
|
1312
|
+
id: z8.string(),
|
|
1313
|
+
question: z8.string(),
|
|
1314
|
+
type: z8.enum(["text", "select", "multi-select", "confirm"]),
|
|
1315
|
+
required: z8.boolean().optional(),
|
|
1316
|
+
options: z8.array(
|
|
1317
|
+
z8.object({
|
|
1318
|
+
label: z8.string(),
|
|
1319
|
+
value: z8.string()
|
|
1263
1320
|
})
|
|
1264
1321
|
).optional(),
|
|
1265
|
-
default:
|
|
1266
|
-
help:
|
|
1267
|
-
dependsOn:
|
|
1268
|
-
questionId:
|
|
1269
|
-
value:
|
|
1322
|
+
default: z8.union([z8.string(), z8.boolean(), z8.array(z8.string())]).optional(),
|
|
1323
|
+
help: z8.string().optional(),
|
|
1324
|
+
dependsOn: z8.object({
|
|
1325
|
+
questionId: z8.string(),
|
|
1326
|
+
value: z8.union([z8.string(), z8.array(z8.string())])
|
|
1270
1327
|
}).optional()
|
|
1271
1328
|
});
|
|
1272
1329
|
function registerQuestionTools(server2) {
|
|
@@ -1274,11 +1331,13 @@ function registerQuestionTools(server2) {
|
|
|
1274
1331
|
"stackwright_pro_present_phase_questions",
|
|
1275
1332
|
"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
1333
|
{
|
|
1277
|
-
phase:
|
|
1278
|
-
questions:
|
|
1334
|
+
phase: z8.string().describe('Phase name for display context, e.g. "designer", "api", "auth"'),
|
|
1335
|
+
questions: jsonCoerce(z8.array(ManifestQuestionSchema).optional()).describe(
|
|
1279
1336
|
"Questions in Question Manifest format. If omitted, questions are read from .stackwright/question-manifest.json using the phase name."
|
|
1280
1337
|
),
|
|
1281
|
-
answers:
|
|
1338
|
+
answers: jsonCoerce(
|
|
1339
|
+
z8.record(z8.string(), z8.union([z8.string(), z8.array(z8.string()), z8.boolean()])).optional()
|
|
1340
|
+
).describe("Previously collected answers used to resolve dependsOn conditions")
|
|
1282
1341
|
},
|
|
1283
1342
|
async ({ phase, questions, answers }) => {
|
|
1284
1343
|
let resolvedQuestions;
|
|
@@ -1329,17 +1388,36 @@ function registerQuestionTools(server2) {
|
|
|
1329
1388
|
}
|
|
1330
1389
|
}
|
|
1331
1390
|
const adapted = adaptQuestions(resolvedQuestions, answers ?? {});
|
|
1391
|
+
const labelSchema = z8.string().max(50, "Value should have at most 50 characters");
|
|
1392
|
+
for (const q of adapted) {
|
|
1393
|
+
for (const opt of q.options) {
|
|
1394
|
+
const check = labelSchema.safeParse(opt.label);
|
|
1395
|
+
if (!check.success) {
|
|
1396
|
+
return {
|
|
1397
|
+
content: [
|
|
1398
|
+
{
|
|
1399
|
+
type: "text",
|
|
1400
|
+
text: JSON.stringify({
|
|
1401
|
+
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.`
|
|
1402
|
+
})
|
|
1403
|
+
}
|
|
1404
|
+
],
|
|
1405
|
+
isError: true
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1332
1410
|
if (adapted.length === 0) {
|
|
1333
1411
|
return {
|
|
1334
1412
|
content: [
|
|
1335
1413
|
{
|
|
1336
1414
|
type: "text",
|
|
1337
|
-
text:
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1415
|
+
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.`
|
|
1416
|
+
},
|
|
1417
|
+
{
|
|
1418
|
+
type: "text",
|
|
1419
|
+
// Empty array — second block always present so the foreman's two-block contract holds
|
|
1420
|
+
text: JSON.stringify([])
|
|
1343
1421
|
}
|
|
1344
1422
|
],
|
|
1345
1423
|
isError: false
|
|
@@ -1360,11 +1438,149 @@ function registerQuestionTools(server2) {
|
|
|
1360
1438
|
};
|
|
1361
1439
|
}
|
|
1362
1440
|
);
|
|
1441
|
+
server2.tool(
|
|
1442
|
+
"stackwright_pro_get_next_question",
|
|
1443
|
+
"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.",
|
|
1444
|
+
{ phase: z8.string().describe('Phase name e.g. "designer", "api", "auth"') },
|
|
1445
|
+
async ({ phase }) => {
|
|
1446
|
+
const SAFE_PHASE = /^[a-z][a-z0-9-]{0,30}$/;
|
|
1447
|
+
if (!SAFE_PHASE.test(phase)) {
|
|
1448
|
+
return {
|
|
1449
|
+
content: [
|
|
1450
|
+
{
|
|
1451
|
+
type: "text",
|
|
1452
|
+
text: JSON.stringify({ error: `Invalid phase name: "${phase.slice(0, 50)}"` })
|
|
1453
|
+
}
|
|
1454
|
+
],
|
|
1455
|
+
isError: true
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
const cwd = process.cwd();
|
|
1459
|
+
const questionsPath = join(cwd, ".stackwright", "questions", `${phase}.json`);
|
|
1460
|
+
let questions = [];
|
|
1461
|
+
try {
|
|
1462
|
+
const raw = await readFile(questionsPath, "utf-8");
|
|
1463
|
+
const parsed = JSON.parse(raw);
|
|
1464
|
+
questions = parsed.questions ?? [];
|
|
1465
|
+
} catch {
|
|
1466
|
+
return { content: [{ type: "text", text: JSON.stringify({ done: true }) }] };
|
|
1467
|
+
}
|
|
1468
|
+
if (questions.length === 0) {
|
|
1469
|
+
return { content: [{ type: "text", text: JSON.stringify({ done: true }) }] };
|
|
1470
|
+
}
|
|
1471
|
+
const answersPath = join(cwd, ".stackwright", "answers", `${phase}.json`);
|
|
1472
|
+
let answeredIds = /* @__PURE__ */ new Set();
|
|
1473
|
+
try {
|
|
1474
|
+
const raw = await readFile(answersPath, "utf-8");
|
|
1475
|
+
const parsed = JSON.parse(raw);
|
|
1476
|
+
answeredIds = new Set(Object.keys(parsed.answers ?? {}));
|
|
1477
|
+
} catch {
|
|
1478
|
+
}
|
|
1479
|
+
const next = questions.find((q) => !answeredIds.has(q.id));
|
|
1480
|
+
if (!next) {
|
|
1481
|
+
return { content: [{ type: "text", text: JSON.stringify({ done: true }) }] };
|
|
1482
|
+
}
|
|
1483
|
+
return {
|
|
1484
|
+
content: [
|
|
1485
|
+
{
|
|
1486
|
+
type: "text",
|
|
1487
|
+
text: JSON.stringify({
|
|
1488
|
+
done: false,
|
|
1489
|
+
questionId: next.id,
|
|
1490
|
+
question: next.question,
|
|
1491
|
+
type: next.type,
|
|
1492
|
+
options: next.options ?? null,
|
|
1493
|
+
help: next.help ?? null,
|
|
1494
|
+
index: questions.indexOf(next) + 1,
|
|
1495
|
+
total: questions.length
|
|
1496
|
+
})
|
|
1497
|
+
}
|
|
1498
|
+
]
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
);
|
|
1502
|
+
server2.tool(
|
|
1503
|
+
"stackwright_pro_record_answer",
|
|
1504
|
+
"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.",
|
|
1505
|
+
{
|
|
1506
|
+
phase: z8.string().describe('Phase name e.g. "designer"'),
|
|
1507
|
+
questionId: z8.string().describe('The question ID from get_next_question, e.g. "designer-1"'),
|
|
1508
|
+
answer: z8.string().describe("The user's free-text answer")
|
|
1509
|
+
},
|
|
1510
|
+
async ({ phase, questionId, answer }) => {
|
|
1511
|
+
const SAFE_PHASE = /^[a-z][a-z0-9-]{0,30}$/;
|
|
1512
|
+
if (!SAFE_PHASE.test(phase)) {
|
|
1513
|
+
return {
|
|
1514
|
+
content: [
|
|
1515
|
+
{ type: "text", text: JSON.stringify({ error: "Invalid phase name" }) }
|
|
1516
|
+
],
|
|
1517
|
+
isError: true
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/i.test(questionId)) {
|
|
1521
|
+
return {
|
|
1522
|
+
content: [
|
|
1523
|
+
{ type: "text", text: JSON.stringify({ error: "Invalid questionId" }) }
|
|
1524
|
+
],
|
|
1525
|
+
isError: true
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
const safeAnswer = answer.slice(0, 2e3);
|
|
1529
|
+
const cwd = process.cwd();
|
|
1530
|
+
const answersDir = join(cwd, ".stackwright", "answers");
|
|
1531
|
+
const answersPath = join(answersDir, `${phase}.json`);
|
|
1532
|
+
if (existsSync2(answersDir) && lstatSync2(answersDir).isSymbolicLink()) {
|
|
1533
|
+
return {
|
|
1534
|
+
content: [
|
|
1535
|
+
{
|
|
1536
|
+
type: "text",
|
|
1537
|
+
text: JSON.stringify({ error: "answers dir is a symlink \u2014 refusing to write" })
|
|
1538
|
+
}
|
|
1539
|
+
],
|
|
1540
|
+
isError: true
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
mkdirSync(answersDir, { recursive: true });
|
|
1544
|
+
let existing = {
|
|
1545
|
+
version: "1.0",
|
|
1546
|
+
phase,
|
|
1547
|
+
answers: {}
|
|
1548
|
+
};
|
|
1549
|
+
try {
|
|
1550
|
+
if (existsSync2(answersPath)) {
|
|
1551
|
+
if (lstatSync2(answersPath).isSymbolicLink()) {
|
|
1552
|
+
return {
|
|
1553
|
+
content: [
|
|
1554
|
+
{
|
|
1555
|
+
type: "text",
|
|
1556
|
+
text: JSON.stringify({ error: "answers file is a symlink \u2014 refusing to write" })
|
|
1557
|
+
}
|
|
1558
|
+
],
|
|
1559
|
+
isError: true
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
const raw = await readFile(answersPath, "utf-8");
|
|
1563
|
+
existing = JSON.parse(raw);
|
|
1564
|
+
}
|
|
1565
|
+
} catch {
|
|
1566
|
+
}
|
|
1567
|
+
existing.answers[questionId] = safeAnswer;
|
|
1568
|
+
existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1569
|
+
const tmp = `${answersPath}.tmp`;
|
|
1570
|
+
await writeFile(tmp, JSON.stringify(existing, null, 2), "utf-8");
|
|
1571
|
+
renameSync(tmp, answersPath);
|
|
1572
|
+
return {
|
|
1573
|
+
content: [
|
|
1574
|
+
{ type: "text", text: JSON.stringify({ recorded: true, phase, questionId }) }
|
|
1575
|
+
]
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
);
|
|
1363
1579
|
}
|
|
1364
1580
|
|
|
1365
1581
|
// src/tools/orchestration.ts
|
|
1366
|
-
import { z as
|
|
1367
|
-
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as
|
|
1582
|
+
import { z as z9 } from "zod";
|
|
1583
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync2, lstatSync as lstatSync3 } from "fs";
|
|
1368
1584
|
import { join as join2 } from "path";
|
|
1369
1585
|
var OTTER_NAME_TO_PHASE = [
|
|
1370
1586
|
["designer", "designer"],
|
|
@@ -1374,7 +1590,9 @@ var OTTER_NAME_TO_PHASE = [
|
|
|
1374
1590
|
["dashboard", "dashboard"],
|
|
1375
1591
|
["data", "data"],
|
|
1376
1592
|
["page", "pages"],
|
|
1377
|
-
["workflow", "workflow"]
|
|
1593
|
+
["workflow", "workflow"],
|
|
1594
|
+
["polish", "polish"],
|
|
1595
|
+
["geo", "geo"]
|
|
1378
1596
|
];
|
|
1379
1597
|
var PHASE_TO_OTTER = {
|
|
1380
1598
|
designer: "stackwright-pro-designer-otter",
|
|
@@ -1384,7 +1602,9 @@ var PHASE_TO_OTTER = {
|
|
|
1384
1602
|
pages: "stackwright-pro-page-otter",
|
|
1385
1603
|
dashboard: "stackwright-pro-dashboard-otter",
|
|
1386
1604
|
data: "stackwright-pro-data-otter",
|
|
1387
|
-
workflow: "stackwright-pro-workflow-otter"
|
|
1605
|
+
workflow: "stackwright-pro-workflow-otter",
|
|
1606
|
+
polish: "stackwright-pro-polish-otter",
|
|
1607
|
+
geo: "stackwright-pro-geo-otter"
|
|
1388
1608
|
};
|
|
1389
1609
|
function detectPhase(otterName) {
|
|
1390
1610
|
const lower = otterName.toLowerCase();
|
|
@@ -1420,9 +1640,9 @@ function handleSaveManifest(input) {
|
|
|
1420
1640
|
const dir = join2(cwd, ".stackwright");
|
|
1421
1641
|
const filePath = join2(dir, "question-manifest.json");
|
|
1422
1642
|
try {
|
|
1423
|
-
|
|
1424
|
-
if (
|
|
1425
|
-
const stat =
|
|
1643
|
+
mkdirSync2(dir, { recursive: true });
|
|
1644
|
+
if (existsSync3(filePath)) {
|
|
1645
|
+
const stat = lstatSync3(filePath);
|
|
1426
1646
|
if (stat.isSymbolicLink()) {
|
|
1427
1647
|
const message = `Refusing to write to symlink: ${filePath}`;
|
|
1428
1648
|
return {
|
|
@@ -1454,7 +1674,7 @@ function handleSavePhaseAnswers(input) {
|
|
|
1454
1674
|
const dir = join2(cwd, ".stackwright", "answers");
|
|
1455
1675
|
const filePath = join2(dir, `${input.phase}.json`);
|
|
1456
1676
|
try {
|
|
1457
|
-
|
|
1677
|
+
mkdirSync2(dir, { recursive: true });
|
|
1458
1678
|
let answers;
|
|
1459
1679
|
if (input.questions && input.questions.length > 0) {
|
|
1460
1680
|
answers = answersToManifestFormat(input.rawAnswers, input.questions);
|
|
@@ -1469,8 +1689,8 @@ function handleSavePhaseAnswers(input) {
|
|
|
1469
1689
|
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1470
1690
|
answers
|
|
1471
1691
|
};
|
|
1472
|
-
if (
|
|
1473
|
-
const stat =
|
|
1692
|
+
if (existsSync3(filePath)) {
|
|
1693
|
+
const stat = lstatSync3(filePath);
|
|
1474
1694
|
if (stat.isSymbolicLink()) {
|
|
1475
1695
|
const message = `Refusing to write to symlink: ${filePath}`;
|
|
1476
1696
|
return {
|
|
@@ -1499,7 +1719,7 @@ function handleSavePhaseAnswers(input) {
|
|
|
1499
1719
|
function handleReadPhaseAnswers(input) {
|
|
1500
1720
|
const cwd = input._cwd ?? process.cwd();
|
|
1501
1721
|
const filePath = join2(cwd, ".stackwright", "answers", `${input.phase}.json`);
|
|
1502
|
-
if (!
|
|
1722
|
+
if (!existsSync3(filePath)) {
|
|
1503
1723
|
return {
|
|
1504
1724
|
text: JSON.stringify({ missing: true, phase: input.phase }),
|
|
1505
1725
|
isError: false
|
|
@@ -1531,13 +1751,63 @@ function handleGetOtterName(input) {
|
|
|
1531
1751
|
isError: false
|
|
1532
1752
|
};
|
|
1533
1753
|
}
|
|
1754
|
+
function handleSaveBuildContext(input) {
|
|
1755
|
+
const cwd = input._cwd ?? process.cwd();
|
|
1756
|
+
const dir = join2(cwd, ".stackwright");
|
|
1757
|
+
const filePath = join2(dir, "build-context.json");
|
|
1758
|
+
try {
|
|
1759
|
+
mkdirSync2(dir, { recursive: true });
|
|
1760
|
+
if (existsSync3(filePath)) {
|
|
1761
|
+
const stat = lstatSync3(filePath);
|
|
1762
|
+
if (stat.isSymbolicLink()) {
|
|
1763
|
+
return {
|
|
1764
|
+
text: JSON.stringify({
|
|
1765
|
+
success: false,
|
|
1766
|
+
error: `Refusing to write to symlink: ${filePath}`
|
|
1767
|
+
}),
|
|
1768
|
+
isError: true
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
const payload = {
|
|
1773
|
+
version: "1.0",
|
|
1774
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1775
|
+
buildContext: input.buildContext
|
|
1776
|
+
};
|
|
1777
|
+
writeFileSync2(filePath, JSON.stringify(payload, null, 2) + "\n");
|
|
1778
|
+
return {
|
|
1779
|
+
text: JSON.stringify({ success: true, path: filePath }),
|
|
1780
|
+
isError: false
|
|
1781
|
+
};
|
|
1782
|
+
} catch (err) {
|
|
1783
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1784
|
+
return {
|
|
1785
|
+
text: JSON.stringify({ success: false, error: message }),
|
|
1786
|
+
isError: true
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1534
1790
|
function registerOrchestrationTools(server2) {
|
|
1791
|
+
server2.tool(
|
|
1792
|
+
"stackwright_pro_save_build_context",
|
|
1793
|
+
`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.`,
|
|
1794
|
+
{
|
|
1795
|
+
buildContext: z9.string().describe("Free-text description of what the user wants to build")
|
|
1796
|
+
},
|
|
1797
|
+
async ({ buildContext }) => {
|
|
1798
|
+
const { text, isError } = handleSaveBuildContext({ buildContext });
|
|
1799
|
+
return {
|
|
1800
|
+
content: [{ type: "text", text }],
|
|
1801
|
+
isError
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
);
|
|
1535
1805
|
server2.tool(
|
|
1536
1806
|
"stackwright_pro_parse_otter_response",
|
|
1537
1807
|
"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
1808
|
{
|
|
1539
|
-
otterName:
|
|
1540
|
-
responseText:
|
|
1809
|
+
otterName: z9.string().describe('The agent name, e.g. "stackwright-pro-api-otter"'),
|
|
1810
|
+
responseText: z9.string().describe("Raw text response from the otter's QUESTION_COLLECTION_MODE invocation")
|
|
1541
1811
|
},
|
|
1542
1812
|
async ({ otterName, responseText }) => {
|
|
1543
1813
|
const { result, isError } = handleParseOtterResponse({ otterName, responseText });
|
|
@@ -1551,16 +1821,18 @@ function registerOrchestrationTools(server2) {
|
|
|
1551
1821
|
"stackwright_pro_save_manifest",
|
|
1552
1822
|
"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
1823
|
{
|
|
1554
|
-
phases:
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1824
|
+
phases: jsonCoerce(
|
|
1825
|
+
z9.array(
|
|
1826
|
+
z9.object({
|
|
1827
|
+
phase: z9.string(),
|
|
1828
|
+
otter: z9.string(),
|
|
1829
|
+
questions: z9.array(z9.any()),
|
|
1830
|
+
requiredPackages: z9.object({
|
|
1831
|
+
dependencies: z9.record(z9.string(), z9.string()).optional(),
|
|
1832
|
+
devPackages: z9.record(z9.string(), z9.string()).optional()
|
|
1833
|
+
}).optional()
|
|
1834
|
+
})
|
|
1835
|
+
)
|
|
1564
1836
|
).describe("Array of parsed phase objects from stackwright_pro_parse_otter_response")
|
|
1565
1837
|
},
|
|
1566
1838
|
async ({ phases }) => {
|
|
@@ -1575,15 +1847,21 @@ function registerOrchestrationTools(server2) {
|
|
|
1575
1847
|
"stackwright_pro_save_phase_answers",
|
|
1576
1848
|
"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
1849
|
{
|
|
1578
|
-
phase:
|
|
1579
|
-
rawAnswers:
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1850
|
+
phase: z9.string().describe('Phase name, e.g. "designer"'),
|
|
1851
|
+
rawAnswers: jsonCoerce(
|
|
1852
|
+
z9.array(
|
|
1853
|
+
z9.object({
|
|
1854
|
+
question_header: z9.string(),
|
|
1855
|
+
selected_options: z9.array(z9.string()),
|
|
1856
|
+
other_text: z9.string().nullable().optional()
|
|
1857
|
+
})
|
|
1858
|
+
)
|
|
1859
|
+
).describe(
|
|
1860
|
+
"Answers as returned by ask_user_question \u2014 pass the native array, not a JSON string"
|
|
1861
|
+
),
|
|
1862
|
+
questions: jsonCoerce(z9.array(z9.any()).optional()).describe(
|
|
1863
|
+
"Original manifest questions for label\u2192value reverse-mapping \u2014 pass the native array, not a JSON string"
|
|
1864
|
+
)
|
|
1587
1865
|
},
|
|
1588
1866
|
async ({ phase, rawAnswers, questions }) => {
|
|
1589
1867
|
const { text, isError } = handleSavePhaseAnswers({
|
|
@@ -1601,7 +1879,7 @@ function registerOrchestrationTools(server2) {
|
|
|
1601
1879
|
"stackwright_pro_read_phase_answers",
|
|
1602
1880
|
"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
1881
|
{
|
|
1604
|
-
phase:
|
|
1882
|
+
phase: z9.string().describe('Phase name, e.g. "designer"')
|
|
1605
1883
|
},
|
|
1606
1884
|
async ({ phase }) => {
|
|
1607
1885
|
const { text, isError } = handleReadPhaseAnswers({ phase });
|
|
@@ -1615,7 +1893,7 @@ function registerOrchestrationTools(server2) {
|
|
|
1615
1893
|
"stackwright_pro_get_otter_name",
|
|
1616
1894
|
"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
1895
|
{
|
|
1618
|
-
phase:
|
|
1896
|
+
phase: z9.string().describe('Phase name, e.g. "designer", "api", "pages"')
|
|
1619
1897
|
},
|
|
1620
1898
|
async ({ phase }) => {
|
|
1621
1899
|
const { text, isError } = handleGetOtterName({ phase });
|
|
@@ -1628,28 +1906,391 @@ function registerOrchestrationTools(server2) {
|
|
|
1628
1906
|
}
|
|
1629
1907
|
|
|
1630
1908
|
// src/tools/pipeline.ts
|
|
1631
|
-
import { z as
|
|
1632
|
-
import { readFileSync as
|
|
1909
|
+
import { z as z11 } from "zod";
|
|
1910
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync5, mkdirSync as mkdirSync4, lstatSync as lstatSync5 } from "fs";
|
|
1911
|
+
import { join as join4 } from "path";
|
|
1912
|
+
import { createHash as createHash3 } from "crypto";
|
|
1913
|
+
|
|
1914
|
+
// src/artifact-signing.ts
|
|
1915
|
+
import {
|
|
1916
|
+
createHash as createHash2,
|
|
1917
|
+
generateKeyPairSync,
|
|
1918
|
+
createPublicKey,
|
|
1919
|
+
createPrivateKey,
|
|
1920
|
+
sign,
|
|
1921
|
+
verify,
|
|
1922
|
+
timingSafeEqual
|
|
1923
|
+
} from "crypto";
|
|
1924
|
+
import {
|
|
1925
|
+
readFileSync as readFileSync3,
|
|
1926
|
+
writeFileSync as writeFileSync3,
|
|
1927
|
+
existsSync as existsSync4,
|
|
1928
|
+
mkdirSync as mkdirSync3,
|
|
1929
|
+
lstatSync as lstatSync4,
|
|
1930
|
+
unlinkSync,
|
|
1931
|
+
readdirSync
|
|
1932
|
+
} from "fs";
|
|
1633
1933
|
import { join as join3 } from "path";
|
|
1934
|
+
import { z as z10 } from "zod";
|
|
1935
|
+
var ALGORITHM = "ECDSA-P384-SHA384";
|
|
1936
|
+
var KEY_FILE = "pipeline-keys.json";
|
|
1937
|
+
var KEY_DIR = ".stackwright";
|
|
1938
|
+
var SIGNATURE_MANIFEST = "signatures.json";
|
|
1939
|
+
var ARTIFACTS_DIR = ".stackwright/artifacts";
|
|
1940
|
+
function rejectSymlink(filePath, context) {
|
|
1941
|
+
if (!existsSync4(filePath)) return;
|
|
1942
|
+
const stat = lstatSync4(filePath);
|
|
1943
|
+
if (stat.isSymbolicLink()) {
|
|
1944
|
+
throw new Error(`Security: refusing to follow symlink at ${context}: ${filePath}`);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
function computeSha384(data) {
|
|
1948
|
+
return createHash2("sha384").update(data).digest("hex");
|
|
1949
|
+
}
|
|
1950
|
+
function safeDigestEqual(a, b) {
|
|
1951
|
+
if (a.length !== b.length) return false;
|
|
1952
|
+
return timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
1953
|
+
}
|
|
1954
|
+
function emptyManifest() {
|
|
1955
|
+
return {
|
|
1956
|
+
version: "1.0",
|
|
1957
|
+
algorithm: ALGORITHM,
|
|
1958
|
+
signatures: {}
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
function initPipelineKeys(cwd) {
|
|
1962
|
+
const keyDir = join3(cwd, KEY_DIR);
|
|
1963
|
+
const keyPath = join3(keyDir, KEY_FILE);
|
|
1964
|
+
rejectSymlink(keyPath, "pipeline-keys.json");
|
|
1965
|
+
mkdirSync3(keyDir, { recursive: true });
|
|
1966
|
+
const { publicKey, privateKey } = generateKeyPairSync("ec", {
|
|
1967
|
+
namedCurve: "P-384"
|
|
1968
|
+
});
|
|
1969
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
|
1970
|
+
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
|
1971
|
+
const fingerprint = createHash2("sha256").update(publicKeyPem).digest("hex");
|
|
1972
|
+
const keyFile = {
|
|
1973
|
+
version: "1.0",
|
|
1974
|
+
algorithm: ALGORITHM,
|
|
1975
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1976
|
+
publicKeyPem,
|
|
1977
|
+
privateKeyPem
|
|
1978
|
+
};
|
|
1979
|
+
writeFileSync3(keyPath, JSON.stringify(keyFile, null, 2), { encoding: "utf-8" });
|
|
1980
|
+
return { publicKeyPem, fingerprint };
|
|
1981
|
+
}
|
|
1982
|
+
function loadPipelineKeys(cwd) {
|
|
1983
|
+
const keyPath = join3(cwd, KEY_DIR, KEY_FILE);
|
|
1984
|
+
rejectSymlink(keyPath, "pipeline-keys.json");
|
|
1985
|
+
if (!existsSync4(keyPath)) {
|
|
1986
|
+
throw new Error("Pipeline keys not found \u2014 call initPipelineKeys() first");
|
|
1987
|
+
}
|
|
1988
|
+
let raw;
|
|
1989
|
+
try {
|
|
1990
|
+
raw = readFileSync3(keyPath, "utf-8");
|
|
1991
|
+
} catch (err) {
|
|
1992
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1993
|
+
throw new Error(`Cannot read pipeline keys: ${msg}`, { cause: err });
|
|
1994
|
+
}
|
|
1995
|
+
let parsed;
|
|
1996
|
+
try {
|
|
1997
|
+
parsed = JSON.parse(raw);
|
|
1998
|
+
} catch {
|
|
1999
|
+
throw new Error("Pipeline keys file is not valid JSON");
|
|
2000
|
+
}
|
|
2001
|
+
if (typeof parsed.publicKeyPem !== "string" || !parsed.publicKeyPem.includes("-----BEGIN PUBLIC KEY-----")) {
|
|
2002
|
+
throw new Error("Invalid public key PEM in pipeline keys file");
|
|
2003
|
+
}
|
|
2004
|
+
if (typeof parsed.privateKeyPem !== "string" || !parsed.privateKeyPem.includes("-----BEGIN")) {
|
|
2005
|
+
throw new Error("Invalid private key PEM in pipeline keys file");
|
|
2006
|
+
}
|
|
2007
|
+
const publicKey = createPublicKey(parsed.publicKeyPem);
|
|
2008
|
+
const privateKey = createPrivateKey(parsed.privateKeyPem);
|
|
2009
|
+
return { privateKey, publicKey };
|
|
2010
|
+
}
|
|
2011
|
+
function signArtifact(artifactBytes, privateKey) {
|
|
2012
|
+
const digest = computeSha384(artifactBytes);
|
|
2013
|
+
const sig = sign("SHA384", artifactBytes, privateKey);
|
|
2014
|
+
return {
|
|
2015
|
+
digest,
|
|
2016
|
+
signature: sig.toString("base64"),
|
|
2017
|
+
algorithm: ALGORITHM,
|
|
2018
|
+
signedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
function verifyArtifact(artifactBytes, signature, publicKey) {
|
|
2022
|
+
const sigValid = verify(
|
|
2023
|
+
"SHA384",
|
|
2024
|
+
artifactBytes,
|
|
2025
|
+
publicKey,
|
|
2026
|
+
Buffer.from(signature.signature, "base64")
|
|
2027
|
+
);
|
|
2028
|
+
if (!sigValid) return false;
|
|
2029
|
+
const actualDigest = computeSha384(artifactBytes);
|
|
2030
|
+
return safeDigestEqual(actualDigest, signature.digest);
|
|
2031
|
+
}
|
|
2032
|
+
function loadSignatureManifest(cwd) {
|
|
2033
|
+
const manifestPath = join3(cwd, ARTIFACTS_DIR, SIGNATURE_MANIFEST);
|
|
2034
|
+
rejectSymlink(manifestPath, "signatures.json");
|
|
2035
|
+
if (!existsSync4(manifestPath)) return emptyManifest();
|
|
2036
|
+
let raw;
|
|
2037
|
+
try {
|
|
2038
|
+
raw = readFileSync3(manifestPath, "utf-8");
|
|
2039
|
+
} catch (err) {
|
|
2040
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2041
|
+
throw new Error(`Cannot read signature manifest: ${msg}`, { cause: err });
|
|
2042
|
+
}
|
|
2043
|
+
try {
|
|
2044
|
+
const parsed = JSON.parse(raw);
|
|
2045
|
+
if (parsed.version !== "1.0" || typeof parsed.signatures !== "object") {
|
|
2046
|
+
throw new Error("Malformed signature manifest: invalid version or missing signatures object");
|
|
2047
|
+
}
|
|
2048
|
+
return parsed;
|
|
2049
|
+
} catch (err) {
|
|
2050
|
+
if (err instanceof Error && err.message.startsWith("Malformed")) throw err;
|
|
2051
|
+
throw new Error("Signature manifest is not valid JSON", { cause: err });
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
function saveArtifactSignature(cwd, artifactFilename, sig, signerOtter) {
|
|
2055
|
+
const artifactsDir = join3(cwd, ARTIFACTS_DIR);
|
|
2056
|
+
const manifestPath = join3(artifactsDir, SIGNATURE_MANIFEST);
|
|
2057
|
+
rejectSymlink(manifestPath, "signatures.json (save)");
|
|
2058
|
+
mkdirSync3(artifactsDir, { recursive: true });
|
|
2059
|
+
const manifest = loadSignatureManifest(cwd);
|
|
2060
|
+
manifest.signatures[artifactFilename] = {
|
|
2061
|
+
...sig,
|
|
2062
|
+
signedBy: signerOtter
|
|
2063
|
+
};
|
|
2064
|
+
writeFileSync3(manifestPath, JSON.stringify(manifest, null, 2), { encoding: "utf-8" });
|
|
2065
|
+
}
|
|
2066
|
+
function getArtifactSignature(cwd, artifactFilename) {
|
|
2067
|
+
const manifest = loadSignatureManifest(cwd);
|
|
2068
|
+
const entry = manifest.signatures[artifactFilename];
|
|
2069
|
+
if (!entry) return null;
|
|
2070
|
+
return {
|
|
2071
|
+
digest: entry.digest,
|
|
2072
|
+
signature: entry.signature,
|
|
2073
|
+
algorithm: entry.algorithm,
|
|
2074
|
+
signedAt: entry.signedAt
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
function emitSignatureAuditEvent(params) {
|
|
2078
|
+
const record = JSON.stringify({
|
|
2079
|
+
level: "AUDIT",
|
|
2080
|
+
event: "ARTIFACT_SIGNATURE_FAIL",
|
|
2081
|
+
timestamp: params.timestamp,
|
|
2082
|
+
source: params.source,
|
|
2083
|
+
artifactFilename: params.artifactFilename,
|
|
2084
|
+
expectedDigest: params.expectedDigest,
|
|
2085
|
+
actualDigest: params.actualDigest,
|
|
2086
|
+
phase: params.phase
|
|
2087
|
+
});
|
|
2088
|
+
process.stderr.write(`ARTIFACT_SIGNATURE_FAIL ${record}
|
|
2089
|
+
`);
|
|
2090
|
+
}
|
|
2091
|
+
function registerArtifactSigningTools(server2) {
|
|
2092
|
+
server2.tool(
|
|
2093
|
+
"stackwright_pro_verify_artifact_signatures",
|
|
2094
|
+
"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.",
|
|
2095
|
+
{
|
|
2096
|
+
cwd: z10.string().optional().describe("Project root directory. Defaults to process.cwd().")
|
|
2097
|
+
},
|
|
2098
|
+
async ({ cwd: cwdParam }) => {
|
|
2099
|
+
const cwd = cwdParam ?? process.cwd();
|
|
2100
|
+
let publicKey;
|
|
2101
|
+
try {
|
|
2102
|
+
const keys = loadPipelineKeys(cwd);
|
|
2103
|
+
publicKey = keys.publicKey;
|
|
2104
|
+
} catch (err) {
|
|
2105
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2106
|
+
return {
|
|
2107
|
+
content: [
|
|
2108
|
+
{
|
|
2109
|
+
type: "text",
|
|
2110
|
+
text: JSON.stringify({
|
|
2111
|
+
error: true,
|
|
2112
|
+
message: `Cannot load pipeline keys: ${msg}`
|
|
2113
|
+
})
|
|
2114
|
+
}
|
|
2115
|
+
],
|
|
2116
|
+
isError: true
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
let manifest;
|
|
2120
|
+
try {
|
|
2121
|
+
manifest = loadSignatureManifest(cwd);
|
|
2122
|
+
} catch (err) {
|
|
2123
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2124
|
+
return {
|
|
2125
|
+
content: [
|
|
2126
|
+
{
|
|
2127
|
+
type: "text",
|
|
2128
|
+
text: JSON.stringify({
|
|
2129
|
+
error: true,
|
|
2130
|
+
message: `Cannot load signature manifest: ${msg}`
|
|
2131
|
+
})
|
|
2132
|
+
}
|
|
2133
|
+
],
|
|
2134
|
+
isError: true
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
const artifactsPath = join3(cwd, ARTIFACTS_DIR);
|
|
2138
|
+
let artifactFiles = [];
|
|
2139
|
+
try {
|
|
2140
|
+
if (existsSync4(artifactsPath)) {
|
|
2141
|
+
artifactFiles = readdirSync(artifactsPath).filter(
|
|
2142
|
+
(f) => f.endsWith(".json") && f !== SIGNATURE_MANIFEST
|
|
2143
|
+
);
|
|
2144
|
+
}
|
|
2145
|
+
} catch {
|
|
2146
|
+
}
|
|
2147
|
+
const results = [];
|
|
2148
|
+
let hasFailure = false;
|
|
2149
|
+
for (const filename of artifactFiles) {
|
|
2150
|
+
const filePath = join3(artifactsPath, filename);
|
|
2151
|
+
try {
|
|
2152
|
+
rejectSymlink(filePath, `artifact ${filename}`);
|
|
2153
|
+
} catch {
|
|
2154
|
+
results.push({
|
|
2155
|
+
filename,
|
|
2156
|
+
verified: false,
|
|
2157
|
+
error: "Refusing to verify symlink"
|
|
2158
|
+
});
|
|
2159
|
+
hasFailure = true;
|
|
2160
|
+
continue;
|
|
2161
|
+
}
|
|
2162
|
+
let artifactBytes;
|
|
2163
|
+
try {
|
|
2164
|
+
artifactBytes = readFileSync3(filePath);
|
|
2165
|
+
} catch (err) {
|
|
2166
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2167
|
+
results.push({
|
|
2168
|
+
filename,
|
|
2169
|
+
verified: false,
|
|
2170
|
+
error: `Cannot read artifact: ${msg}`
|
|
2171
|
+
});
|
|
2172
|
+
hasFailure = true;
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
const entry = manifest.signatures[filename];
|
|
2176
|
+
if (!entry) {
|
|
2177
|
+
results.push({
|
|
2178
|
+
filename,
|
|
2179
|
+
verified: false,
|
|
2180
|
+
error: "No signature found in manifest"
|
|
2181
|
+
});
|
|
2182
|
+
hasFailure = true;
|
|
2183
|
+
continue;
|
|
2184
|
+
}
|
|
2185
|
+
const sig = {
|
|
2186
|
+
digest: entry.digest,
|
|
2187
|
+
signature: entry.signature,
|
|
2188
|
+
algorithm: entry.algorithm,
|
|
2189
|
+
signedAt: entry.signedAt
|
|
2190
|
+
};
|
|
2191
|
+
const verified = verifyArtifact(artifactBytes, sig, publicKey);
|
|
2192
|
+
if (!verified) {
|
|
2193
|
+
const actualDigest = computeSha384(artifactBytes);
|
|
2194
|
+
emitSignatureAuditEvent({
|
|
2195
|
+
artifactFilename: filename,
|
|
2196
|
+
expectedDigest: sig.digest,
|
|
2197
|
+
actualDigest,
|
|
2198
|
+
phase: entry.signedBy ?? "unknown",
|
|
2199
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2200
|
+
source: "stackwright_pro_verify_artifact_signatures"
|
|
2201
|
+
});
|
|
2202
|
+
results.push({
|
|
2203
|
+
filename,
|
|
2204
|
+
verified: false,
|
|
2205
|
+
error: `Signature verification failed \u2014 artifact may have been tampered with`,
|
|
2206
|
+
signedBy: entry.signedBy,
|
|
2207
|
+
signedAt: entry.signedAt
|
|
2208
|
+
});
|
|
2209
|
+
hasFailure = true;
|
|
2210
|
+
} else {
|
|
2211
|
+
results.push({
|
|
2212
|
+
filename,
|
|
2213
|
+
verified: true,
|
|
2214
|
+
signedBy: entry.signedBy,
|
|
2215
|
+
signedAt: entry.signedAt
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
for (const manifestFilename of Object.keys(manifest.signatures)) {
|
|
2220
|
+
if (!artifactFiles.includes(manifestFilename)) {
|
|
2221
|
+
results.push({
|
|
2222
|
+
filename: manifestFilename,
|
|
2223
|
+
verified: false,
|
|
2224
|
+
error: "Artifact referenced in manifest but missing from disk"
|
|
2225
|
+
});
|
|
2226
|
+
hasFailure = true;
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
const verifiedCount = results.filter((r) => r.verified).length;
|
|
2230
|
+
const failedCount = results.filter((r) => !r.verified).length;
|
|
2231
|
+
return {
|
|
2232
|
+
content: [
|
|
2233
|
+
{
|
|
2234
|
+
type: "text",
|
|
2235
|
+
text: JSON.stringify({
|
|
2236
|
+
totalArtifacts: artifactFiles.length,
|
|
2237
|
+
verifiedCount,
|
|
2238
|
+
failedCount,
|
|
2239
|
+
results,
|
|
2240
|
+
...hasFailure ? {
|
|
2241
|
+
error: "SIGNATURE VERIFICATION FAILED: One or more artifact signatures are invalid. Do not proceed \u2014 artifacts may have been tampered with."
|
|
2242
|
+
} : {}
|
|
2243
|
+
})
|
|
2244
|
+
}
|
|
2245
|
+
],
|
|
2246
|
+
isError: hasFailure
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// src/tools/pipeline.ts
|
|
2253
|
+
import { WorkflowFileSchema, authConfigSchema } from "@stackwright-pro/types";
|
|
1634
2254
|
var PHASE_ORDER = [
|
|
1635
2255
|
"designer",
|
|
1636
2256
|
"theme",
|
|
1637
2257
|
"api",
|
|
1638
|
-
"auth",
|
|
1639
2258
|
"data",
|
|
2259
|
+
"geo",
|
|
2260
|
+
"workflow",
|
|
2261
|
+
"services",
|
|
1640
2262
|
"pages",
|
|
1641
2263
|
"dashboard",
|
|
1642
|
-
"
|
|
2264
|
+
"auth",
|
|
2265
|
+
"polish"
|
|
1643
2266
|
];
|
|
1644
2267
|
var PHASE_DEPENDENCIES = {
|
|
1645
2268
|
designer: [],
|
|
1646
2269
|
theme: ["designer"],
|
|
1647
2270
|
api: [],
|
|
1648
|
-
auth: [],
|
|
1649
2271
|
data: ["api"],
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
2272
|
+
geo: ["data"],
|
|
2273
|
+
// workflow: no hard deps — uses service: references with Prism mock fallback
|
|
2274
|
+
// when API artifacts aren't available; roles come from user answers
|
|
2275
|
+
workflow: [],
|
|
2276
|
+
// services: needs API endpoints to know what to wire, and optionally
|
|
2277
|
+
// workflow states as trigger sources. Produces OpenAPI specs consumed
|
|
2278
|
+
// by pages and dashboard for typed data wiring.
|
|
2279
|
+
services: ["api", "workflow"],
|
|
2280
|
+
// pages/dashboard: now also depend on services for typed endpoint wiring
|
|
2281
|
+
// via the OpenAPI specs that services produces.
|
|
2282
|
+
// 'api' is still transitive through 'data'; auth removed — page-otter has
|
|
2283
|
+
// graceful fallback and reads role names from workflow artifacts instead
|
|
2284
|
+
pages: ["designer", "theme", "data", "services"],
|
|
2285
|
+
dashboard: ["designer", "theme", "data", "services"],
|
|
2286
|
+
// auth is the penultimate phase — runs after all content-producing phases
|
|
2287
|
+
// so it can read pages, dashboard, workflow, and geo artifacts for route
|
|
2288
|
+
// protection and RBAC wiring. Skipped upstream phases still satisfy deps
|
|
2289
|
+
// (the foreman marks them executed=true), so auth always runs.
|
|
2290
|
+
auth: ["pages", "dashboard", "workflow", "geo"],
|
|
2291
|
+
// polish is the terminal phase — runs after auth so the landing page and
|
|
2292
|
+
// nav reflect the final set of protected routes and all generated pages.
|
|
2293
|
+
polish: ["auth"]
|
|
1653
2294
|
};
|
|
1654
2295
|
var PHASE_ARTIFACT = {
|
|
1655
2296
|
designer: "design-language.json",
|
|
@@ -1659,7 +2300,10 @@ var PHASE_ARTIFACT = {
|
|
|
1659
2300
|
data: "data-config.json",
|
|
1660
2301
|
pages: "pages-manifest.json",
|
|
1661
2302
|
dashboard: "dashboard-manifest.json",
|
|
1662
|
-
workflow: "workflow-config.json"
|
|
2303
|
+
workflow: "workflow-config.json",
|
|
2304
|
+
services: "services-config.json",
|
|
2305
|
+
polish: "polish-manifest.json",
|
|
2306
|
+
geo: "geo-manifest.json"
|
|
1663
2307
|
};
|
|
1664
2308
|
var PHASE_TO_OTTER2 = {
|
|
1665
2309
|
designer: "stackwright-pro-designer-otter",
|
|
@@ -1669,7 +2313,10 @@ var PHASE_TO_OTTER2 = {
|
|
|
1669
2313
|
data: "stackwright-pro-data-otter",
|
|
1670
2314
|
pages: "stackwright-pro-page-otter",
|
|
1671
2315
|
dashboard: "stackwright-pro-dashboard-otter",
|
|
1672
|
-
workflow: "stackwright-pro-workflow-otter"
|
|
2316
|
+
workflow: "stackwright-pro-workflow-otter",
|
|
2317
|
+
services: "stackwright-services-otter",
|
|
2318
|
+
polish: "stackwright-pro-polish-otter",
|
|
2319
|
+
geo: "stackwright-pro-geo-otter"
|
|
1673
2320
|
};
|
|
1674
2321
|
function isValidPhase(phase) {
|
|
1675
2322
|
return PHASE_ORDER.includes(phase);
|
|
@@ -1699,11 +2346,11 @@ function createDefaultState() {
|
|
|
1699
2346
|
};
|
|
1700
2347
|
}
|
|
1701
2348
|
function statePath(cwd) {
|
|
1702
|
-
return
|
|
2349
|
+
return join4(cwd, ".stackwright", "pipeline-state.json");
|
|
1703
2350
|
}
|
|
1704
2351
|
function readState(cwd) {
|
|
1705
2352
|
const p = statePath(cwd);
|
|
1706
|
-
if (!
|
|
2353
|
+
if (!existsSync5(p)) return createDefaultState();
|
|
1707
2354
|
const raw = JSON.parse(safeReadSync(p));
|
|
1708
2355
|
if (typeof raw !== "object" || raw === null || raw.version !== "1.0") {
|
|
1709
2356
|
return createDefaultState();
|
|
@@ -1711,26 +2358,26 @@ function readState(cwd) {
|
|
|
1711
2358
|
return raw;
|
|
1712
2359
|
}
|
|
1713
2360
|
function safeWriteSync(filePath, content) {
|
|
1714
|
-
if (
|
|
1715
|
-
const stat =
|
|
2361
|
+
if (existsSync5(filePath)) {
|
|
2362
|
+
const stat = lstatSync5(filePath);
|
|
1716
2363
|
if (stat.isSymbolicLink()) {
|
|
1717
2364
|
throw new Error(`Refusing to write to symlink: ${filePath}`);
|
|
1718
2365
|
}
|
|
1719
2366
|
}
|
|
1720
|
-
|
|
2367
|
+
writeFileSync4(filePath, content);
|
|
1721
2368
|
}
|
|
1722
2369
|
function safeReadSync(filePath) {
|
|
1723
|
-
if (
|
|
1724
|
-
const stat =
|
|
2370
|
+
if (existsSync5(filePath)) {
|
|
2371
|
+
const stat = lstatSync5(filePath);
|
|
1725
2372
|
if (stat.isSymbolicLink()) {
|
|
1726
2373
|
throw new Error(`Refusing to read symlink: ${filePath}`);
|
|
1727
2374
|
}
|
|
1728
2375
|
}
|
|
1729
|
-
return
|
|
2376
|
+
return readFileSync4(filePath, "utf-8");
|
|
1730
2377
|
}
|
|
1731
2378
|
function writeState(cwd, state) {
|
|
1732
|
-
const dir =
|
|
1733
|
-
|
|
2379
|
+
const dir = join4(cwd, ".stackwright");
|
|
2380
|
+
mkdirSync4(dir, { recursive: true });
|
|
1734
2381
|
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1735
2382
|
safeWriteSync(statePath(cwd), JSON.stringify(state, null, 2) + "\n");
|
|
1736
2383
|
}
|
|
@@ -1751,6 +2398,15 @@ function handleGetPipelineState(_cwd) {
|
|
|
1751
2398
|
const cwd = _cwd ?? process.cwd();
|
|
1752
2399
|
try {
|
|
1753
2400
|
const state = readState(cwd);
|
|
2401
|
+
const keyPath = join4(cwd, ".stackwright", "pipeline-keys.json");
|
|
2402
|
+
if (!existsSync5(keyPath)) {
|
|
2403
|
+
try {
|
|
2404
|
+
const { fingerprint } = initPipelineKeys(cwd);
|
|
2405
|
+
state["signingKeyFingerprint"] = fingerprint;
|
|
2406
|
+
writeState(cwd, state);
|
|
2407
|
+
} catch {
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
1754
2410
|
return { text: JSON.stringify(state), isError: false };
|
|
1755
2411
|
} catch (err) {
|
|
1756
2412
|
return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };
|
|
@@ -1794,36 +2450,70 @@ function handleSetPipelineState(input) {
|
|
|
1794
2450
|
if (input.incrementRetry) {
|
|
1795
2451
|
phaseState.retryCount += 1;
|
|
1796
2452
|
}
|
|
1797
|
-
state.currentPhase = phase;
|
|
2453
|
+
state.currentPhase = phase;
|
|
2454
|
+
}
|
|
2455
|
+
writeState(cwd, state);
|
|
2456
|
+
return { text: JSON.stringify(state), isError: false };
|
|
2457
|
+
} catch (err) {
|
|
2458
|
+
return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
function handleCheckExecutionReady(_cwd, phase) {
|
|
2462
|
+
const cwd = _cwd ?? process.cwd();
|
|
2463
|
+
if (phase) {
|
|
2464
|
+
if (!isValidPhase(phase)) {
|
|
2465
|
+
return {
|
|
2466
|
+
text: JSON.stringify({
|
|
2467
|
+
error: true,
|
|
2468
|
+
message: `Invalid phase: ${phase}. Valid phases are: ${PHASE_ORDER.join(", ")}`
|
|
2469
|
+
}),
|
|
2470
|
+
isError: true
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
const answerFile = join4(cwd, ".stackwright", "answers", `${phase}.json`);
|
|
2474
|
+
if (!existsSync5(answerFile)) {
|
|
2475
|
+
return {
|
|
2476
|
+
text: JSON.stringify({ ready: false, phase, reason: "Answer file not found" }),
|
|
2477
|
+
isError: false
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
try {
|
|
2481
|
+
const raw = safeReadSync(answerFile);
|
|
2482
|
+
const parsed = JSON.parse(raw);
|
|
2483
|
+
if (typeof parsed["version"] !== "string" || typeof parsed["phase"] !== "string" || typeof parsed["answers"] !== "object" || parsed["answers"] === null) {
|
|
2484
|
+
return {
|
|
2485
|
+
text: JSON.stringify({ ready: false, phase, reason: "Answer file is malformed" }),
|
|
2486
|
+
isError: false
|
|
2487
|
+
};
|
|
2488
|
+
}
|
|
2489
|
+
return { text: JSON.stringify({ ready: true, phase }), isError: false };
|
|
2490
|
+
} catch {
|
|
2491
|
+
return {
|
|
2492
|
+
text: JSON.stringify({ ready: false, phase, reason: "Answer file could not be parsed" }),
|
|
2493
|
+
isError: false
|
|
2494
|
+
};
|
|
1798
2495
|
}
|
|
1799
|
-
writeState(cwd, state);
|
|
1800
|
-
return { text: JSON.stringify(state), isError: false };
|
|
1801
|
-
} catch (err) {
|
|
1802
|
-
return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };
|
|
1803
2496
|
}
|
|
1804
|
-
}
|
|
1805
|
-
function handleCheckExecutionReady(_cwd) {
|
|
1806
|
-
const cwd = _cwd ?? process.cwd();
|
|
1807
2497
|
try {
|
|
1808
|
-
const answersDir =
|
|
2498
|
+
const answersDir = join4(cwd, ".stackwright", "answers");
|
|
1809
2499
|
const answeredPhases = [];
|
|
1810
2500
|
const missingPhases = [];
|
|
1811
|
-
for (const
|
|
1812
|
-
const answerFile =
|
|
1813
|
-
if (
|
|
2501
|
+
for (const phase2 of PHASE_ORDER) {
|
|
2502
|
+
const answerFile = join4(answersDir, `${phase2}.json`);
|
|
2503
|
+
if (existsSync5(answerFile)) {
|
|
1814
2504
|
try {
|
|
1815
2505
|
const raw = safeReadSync(answerFile);
|
|
1816
2506
|
const parsed = JSON.parse(raw);
|
|
1817
2507
|
if (typeof parsed["version"] !== "string" || typeof parsed["phase"] !== "string" || typeof parsed["answers"] !== "object" || parsed["answers"] === null) {
|
|
1818
|
-
missingPhases.push(
|
|
2508
|
+
missingPhases.push(phase2);
|
|
1819
2509
|
continue;
|
|
1820
2510
|
}
|
|
1821
|
-
answeredPhases.push(
|
|
2511
|
+
answeredPhases.push(phase2);
|
|
1822
2512
|
} catch {
|
|
1823
|
-
missingPhases.push(
|
|
2513
|
+
missingPhases.push(phase2);
|
|
1824
2514
|
}
|
|
1825
2515
|
} else {
|
|
1826
|
-
missingPhases.push(
|
|
2516
|
+
missingPhases.push(phase2);
|
|
1827
2517
|
}
|
|
1828
2518
|
}
|
|
1829
2519
|
return {
|
|
@@ -1839,18 +2529,74 @@ function handleCheckExecutionReady(_cwd) {
|
|
|
1839
2529
|
return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };
|
|
1840
2530
|
}
|
|
1841
2531
|
}
|
|
2532
|
+
function handleGetReadyPhases(_cwd) {
|
|
2533
|
+
const cwd = _cwd ?? process.cwd();
|
|
2534
|
+
try {
|
|
2535
|
+
const state = readState(cwd);
|
|
2536
|
+
const completed = [];
|
|
2537
|
+
const ready = [];
|
|
2538
|
+
const blocked = [];
|
|
2539
|
+
for (const phase of PHASE_ORDER) {
|
|
2540
|
+
const ps = state.phases[phase];
|
|
2541
|
+
if (ps?.executed) {
|
|
2542
|
+
completed.push(phase);
|
|
2543
|
+
continue;
|
|
2544
|
+
}
|
|
2545
|
+
const deps = PHASE_DEPENDENCIES[phase];
|
|
2546
|
+
const unmetDeps = deps.filter((dep) => !state.phases[dep]?.executed);
|
|
2547
|
+
if (unmetDeps.length === 0) {
|
|
2548
|
+
ready.push(phase);
|
|
2549
|
+
} else {
|
|
2550
|
+
blocked.push(phase);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
return {
|
|
2554
|
+
text: JSON.stringify({
|
|
2555
|
+
readyPhases: ready,
|
|
2556
|
+
completedPhases: completed,
|
|
2557
|
+
blockedPhases: blocked,
|
|
2558
|
+
waveSize: ready.length,
|
|
2559
|
+
allComplete: ready.length === 0 && blocked.length === 0
|
|
2560
|
+
}),
|
|
2561
|
+
isError: false
|
|
2562
|
+
};
|
|
2563
|
+
} catch (err) {
|
|
2564
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2565
|
+
return { text: JSON.stringify({ error: true, message }), isError: true };
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
1842
2568
|
function handleListArtifacts(_cwd) {
|
|
1843
2569
|
const cwd = _cwd ?? process.cwd();
|
|
1844
2570
|
try {
|
|
1845
|
-
const artifactsDir =
|
|
2571
|
+
const artifactsDir = join4(cwd, ".stackwright", "artifacts");
|
|
2572
|
+
let manifest = null;
|
|
2573
|
+
try {
|
|
2574
|
+
manifest = loadSignatureManifest(cwd);
|
|
2575
|
+
} catch {
|
|
2576
|
+
}
|
|
1846
2577
|
const artifacts = [];
|
|
1847
2578
|
let completedCount = 0;
|
|
1848
2579
|
for (const phase of PHASE_ORDER) {
|
|
1849
2580
|
const expectedFile = PHASE_ARTIFACT[phase];
|
|
1850
|
-
const fullPath =
|
|
1851
|
-
const exists =
|
|
2581
|
+
const fullPath = join4(artifactsDir, expectedFile);
|
|
2582
|
+
const exists = existsSync5(fullPath);
|
|
1852
2583
|
if (exists) completedCount++;
|
|
1853
|
-
|
|
2584
|
+
let signed = false;
|
|
2585
|
+
let signatureValid = null;
|
|
2586
|
+
if (exists && manifest) {
|
|
2587
|
+
const entry = manifest.signatures[expectedFile];
|
|
2588
|
+
if (entry) {
|
|
2589
|
+
signed = true;
|
|
2590
|
+
try {
|
|
2591
|
+
const rawBytes = Buffer.from(safeReadSync(fullPath), "utf-8");
|
|
2592
|
+
const { publicKey } = loadPipelineKeys(cwd);
|
|
2593
|
+
signatureValid = verifyArtifact(rawBytes, entry, publicKey);
|
|
2594
|
+
} catch {
|
|
2595
|
+
signatureValid = null;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
artifacts.push({ phase, expectedFile, exists, path: fullPath, signed, signatureValid });
|
|
1854
2600
|
}
|
|
1855
2601
|
return {
|
|
1856
2602
|
text: JSON.stringify({ artifacts, completedCount, totalCount: PHASE_ORDER.length }),
|
|
@@ -1886,9 +2632,9 @@ function handleWritePhaseQuestions(input) {
|
|
|
1886
2632
|
}
|
|
1887
2633
|
} catch {
|
|
1888
2634
|
}
|
|
1889
|
-
const questionsDir =
|
|
1890
|
-
|
|
1891
|
-
const filePath =
|
|
2635
|
+
const questionsDir = join4(cwd, ".stackwright", "questions");
|
|
2636
|
+
mkdirSync4(questionsDir, { recursive: true });
|
|
2637
|
+
const filePath = join4(questionsDir, `${phase}.json`);
|
|
1892
2638
|
const payload = {
|
|
1893
2639
|
version: "1.0",
|
|
1894
2640
|
phase,
|
|
@@ -1928,36 +2674,81 @@ function handleBuildSpecialistPrompt(input) {
|
|
|
1928
2674
|
};
|
|
1929
2675
|
}
|
|
1930
2676
|
try {
|
|
1931
|
-
const answersPath =
|
|
2677
|
+
const answersPath = join4(cwd, ".stackwright", "answers", `${phase}.json`);
|
|
1932
2678
|
let answers = {};
|
|
1933
|
-
if (
|
|
2679
|
+
if (existsSync5(answersPath)) {
|
|
1934
2680
|
answers = JSON.parse(safeReadSync(answersPath));
|
|
1935
2681
|
}
|
|
2682
|
+
let buildContextText = "";
|
|
2683
|
+
const buildContextPath = join4(cwd, ".stackwright", "build-context.json");
|
|
2684
|
+
if (existsSync5(buildContextPath)) {
|
|
2685
|
+
try {
|
|
2686
|
+
const bcRaw = JSON.parse(safeReadSync(buildContextPath));
|
|
2687
|
+
if (typeof bcRaw.buildContext === "string" && bcRaw.buildContext.trim().length > 0) {
|
|
2688
|
+
buildContextText = bcRaw.buildContext.trim();
|
|
2689
|
+
}
|
|
2690
|
+
} catch {
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
1936
2693
|
const deps = PHASE_DEPENDENCIES[phase];
|
|
1937
2694
|
const artifactSections = [];
|
|
1938
2695
|
const missingDependencies = [];
|
|
1939
2696
|
for (const dep of deps) {
|
|
1940
2697
|
const artifactFile = PHASE_ARTIFACT[dep];
|
|
1941
|
-
const artifactPath =
|
|
1942
|
-
if (
|
|
1943
|
-
const
|
|
2698
|
+
const artifactPath = join4(cwd, ".stackwright", "artifacts", artifactFile);
|
|
2699
|
+
if (existsSync5(artifactPath)) {
|
|
2700
|
+
const rawContent = safeReadSync(artifactPath);
|
|
2701
|
+
const rawBytes = Buffer.from(rawContent, "utf-8");
|
|
2702
|
+
const content = JSON.parse(rawContent);
|
|
2703
|
+
let signatureVerified = false;
|
|
2704
|
+
let signatureAvailable = false;
|
|
2705
|
+
try {
|
|
2706
|
+
const sig = getArtifactSignature(cwd, artifactFile);
|
|
2707
|
+
if (sig) {
|
|
2708
|
+
signatureAvailable = true;
|
|
2709
|
+
const { publicKey } = loadPipelineKeys(cwd);
|
|
2710
|
+
signatureVerified = verifyArtifact(rawBytes, sig, publicKey);
|
|
2711
|
+
if (!signatureVerified) {
|
|
2712
|
+
const actualDigest = createHash3("sha384").update(rawBytes).digest("hex");
|
|
2713
|
+
emitSignatureAuditEvent({
|
|
2714
|
+
artifactFilename: artifactFile,
|
|
2715
|
+
expectedDigest: sig.digest,
|
|
2716
|
+
actualDigest,
|
|
2717
|
+
phase: dep,
|
|
2718
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2719
|
+
source: "stackwright_pro_build_specialist_prompt"
|
|
2720
|
+
});
|
|
2721
|
+
missingDependencies.push(dep);
|
|
2722
|
+
artifactSections.push(
|
|
2723
|
+
`[${artifactFile}]:
|
|
2724
|
+
(integrity check failed: ECDSA-P384 signature verification failed \u2014 artifact may have been tampered with)`
|
|
2725
|
+
);
|
|
2726
|
+
continue;
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
} catch {
|
|
2730
|
+
}
|
|
1944
2731
|
const expectedOtter = PHASE_TO_OTTER2[dep];
|
|
1945
2732
|
const artifactOtter = content["generatedBy"];
|
|
2733
|
+
const normalizedOtter = artifactOtter?.replace(/-[a-f0-9]{6}$/, "");
|
|
1946
2734
|
if (!artifactOtter) {
|
|
1947
2735
|
missingDependencies.push(dep);
|
|
1948
2736
|
artifactSections.push(
|
|
1949
2737
|
`[${artifactFile}]:
|
|
1950
2738
|
(integrity check failed: missing generatedBy field)`
|
|
1951
2739
|
);
|
|
1952
|
-
} else if (
|
|
2740
|
+
} else if (normalizedOtter !== expectedOtter) {
|
|
1953
2741
|
missingDependencies.push(dep);
|
|
1954
2742
|
artifactSections.push(
|
|
1955
2743
|
`[${artifactFile}]:
|
|
1956
2744
|
(integrity check failed: artifact claims generatedBy="${artifactOtter}" but expected="${expectedOtter}")`
|
|
1957
2745
|
);
|
|
1958
2746
|
} else {
|
|
1959
|
-
|
|
1960
|
-
|
|
2747
|
+
const sigStatus = signatureAvailable ? signatureVerified ? " [signature verified]" : " [signature check skipped]" : " [unsigned]";
|
|
2748
|
+
artifactSections.push(
|
|
2749
|
+
`[${artifactFile}]${sigStatus}:
|
|
2750
|
+
${JSON.stringify(content, null, 2)}`
|
|
2751
|
+
);
|
|
1961
2752
|
}
|
|
1962
2753
|
} else {
|
|
1963
2754
|
missingDependencies.push(dep);
|
|
@@ -1965,10 +2756,17 @@ ${JSON.stringify(content, null, 2)}`);
|
|
|
1965
2756
|
(not yet available)`);
|
|
1966
2757
|
}
|
|
1967
2758
|
}
|
|
1968
|
-
const parts = [
|
|
2759
|
+
const parts = [];
|
|
2760
|
+
if (buildContextText) {
|
|
2761
|
+
parts.push("BUILD_CONTEXT:", buildContextText, "");
|
|
2762
|
+
}
|
|
2763
|
+
parts.push("ANSWERS:", JSON.stringify(answers, null, 2));
|
|
1969
2764
|
if (artifactSections.length > 0) {
|
|
1970
2765
|
parts.push("", "UPSTREAM ARTIFACTS:", "", ...artifactSections);
|
|
1971
2766
|
}
|
|
2767
|
+
const artifactSchema = PHASE_ARTIFACT_SCHEMA[phase];
|
|
2768
|
+
parts.push("", "REQUIRED_ARTIFACT_SCHEMA:");
|
|
2769
|
+
parts.push(artifactSchema);
|
|
1972
2770
|
parts.push("", "Execute using these answers and the upstream artifacts provided.");
|
|
1973
2771
|
const prompt = parts.join("\n");
|
|
1974
2772
|
const dependenciesSatisfied = missingDependencies.length === 0;
|
|
@@ -2003,45 +2801,301 @@ var OFF_SCRIPT_PATTERNS = [
|
|
|
2003
2801
|
var PHASE_REQUIRED_KEYS = {
|
|
2004
2802
|
designer: ["designLanguage", "themeTokenSeeds"],
|
|
2005
2803
|
theme: ["tokens"],
|
|
2006
|
-
api: ["entities"],
|
|
2804
|
+
api: ["entities", "version", "generatedBy"],
|
|
2007
2805
|
auth: ["version", "generatedBy"],
|
|
2008
|
-
data: ["version", "generatedBy"],
|
|
2806
|
+
data: ["version", "generatedBy", "strategy", "collections"],
|
|
2009
2807
|
pages: ["version", "generatedBy"],
|
|
2010
2808
|
dashboard: ["version", "generatedBy"],
|
|
2011
|
-
workflow: ["version", "generatedBy"]
|
|
2809
|
+
workflow: ["version", "generatedBy"],
|
|
2810
|
+
services: ["version", "generatedBy", "flows"],
|
|
2811
|
+
polish: ["version", "generatedBy"],
|
|
2812
|
+
geo: ["version", "generatedBy", "geoCollections"]
|
|
2813
|
+
};
|
|
2814
|
+
var PHASE_ARTIFACT_SCHEMA = {
|
|
2815
|
+
designer: JSON.stringify(
|
|
2816
|
+
{
|
|
2817
|
+
version: "1.0",
|
|
2818
|
+
generatedBy: "stackwright-pro-designer-otter",
|
|
2819
|
+
application: {
|
|
2820
|
+
type: "<operational|data-explorer|admin|logistics|general>",
|
|
2821
|
+
environment: "<workstation|field|control-room|mixed>",
|
|
2822
|
+
density: "<compact|balanced|spacious>",
|
|
2823
|
+
accessibility: "<wcag-aa|section-508|none>",
|
|
2824
|
+
colorScheme: "<light|dark|both>"
|
|
2825
|
+
},
|
|
2826
|
+
designLanguage: {
|
|
2827
|
+
rationale: "<design rationale>",
|
|
2828
|
+
spacingScale: { base: 8, scale: [0, 4, 8, 16, 24, 32, 48, 64] },
|
|
2829
|
+
colorSemantics: { primary: "#1a365d", accent: "#e53e3e" },
|
|
2830
|
+
typography: {
|
|
2831
|
+
dataFont: "Inter",
|
|
2832
|
+
headingFont: "Inter",
|
|
2833
|
+
monoFont: "monospace",
|
|
2834
|
+
dataSizePx: 12,
|
|
2835
|
+
bodySizePx: 14
|
|
2836
|
+
},
|
|
2837
|
+
contrastRatio: "4.5",
|
|
2838
|
+
borderRadius: "4",
|
|
2839
|
+
shadowElevation: "standard"
|
|
2840
|
+
},
|
|
2841
|
+
themeTokenSeeds: {
|
|
2842
|
+
light: {
|
|
2843
|
+
background: "#ffffff",
|
|
2844
|
+
foreground: "#1a1a1a",
|
|
2845
|
+
primary: "#1a365d",
|
|
2846
|
+
surface: "#f7f7f7",
|
|
2847
|
+
border: "#e2e8f0"
|
|
2848
|
+
},
|
|
2849
|
+
dark: {
|
|
2850
|
+
background: "#1a1a1a",
|
|
2851
|
+
foreground: "#ffffff",
|
|
2852
|
+
primary: "#90cdf4",
|
|
2853
|
+
surface: "#2d2d2d",
|
|
2854
|
+
border: "#4a5568"
|
|
2855
|
+
}
|
|
2856
|
+
},
|
|
2857
|
+
conformsTo: null,
|
|
2858
|
+
operationalNotes: []
|
|
2859
|
+
},
|
|
2860
|
+
null,
|
|
2861
|
+
2
|
|
2862
|
+
),
|
|
2863
|
+
theme: JSON.stringify(
|
|
2864
|
+
{
|
|
2865
|
+
version: "1.0",
|
|
2866
|
+
generatedBy: "stackwright-pro-theme-otter",
|
|
2867
|
+
componentLibrary: "shadcn",
|
|
2868
|
+
colorScheme: "<light|dark|both>",
|
|
2869
|
+
tokens: {
|
|
2870
|
+
colors: { "primary-500": "#1a365d", background: "#ffffff" },
|
|
2871
|
+
spacing: { "spacing-1": "8px", "spacing-2": "16px" },
|
|
2872
|
+
typography: { "font-data": "Inter", "text-sm": "12px" },
|
|
2873
|
+
shape: { "radius-sm": "4px", "radius-md": "8px" },
|
|
2874
|
+
shadows: { "shadow-sm": "0 1px 2px rgba(0,0,0,0.08)" }
|
|
2875
|
+
},
|
|
2876
|
+
cssVariables: {
|
|
2877
|
+
"--background": "0 0% 100%",
|
|
2878
|
+
"--foreground": "222.2 84% 4.9%",
|
|
2879
|
+
"--primary": "222.2 47.4% 11.2%",
|
|
2880
|
+
"--primary-foreground": "210 40% 98%",
|
|
2881
|
+
"--surface": "210 40% 98%",
|
|
2882
|
+
"--border": "214.3 31.8% 91.4%"
|
|
2883
|
+
},
|
|
2884
|
+
dark: { "--background": "222.2 84% 4.9%", "--foreground": "210 40% 98%" }
|
|
2885
|
+
},
|
|
2886
|
+
null,
|
|
2887
|
+
2
|
|
2888
|
+
),
|
|
2889
|
+
api: JSON.stringify(
|
|
2890
|
+
{
|
|
2891
|
+
version: "1.0",
|
|
2892
|
+
generatedBy: "stackwright-pro-api-otter",
|
|
2893
|
+
entities: [
|
|
2894
|
+
{
|
|
2895
|
+
name: "Shipment",
|
|
2896
|
+
endpoint: "/shipments",
|
|
2897
|
+
method: "GET",
|
|
2898
|
+
revalidate: 60,
|
|
2899
|
+
mutationType: null
|
|
2900
|
+
}
|
|
2901
|
+
],
|
|
2902
|
+
auth: { type: "bearer", header: "Authorization", envVar: "API_TOKEN" },
|
|
2903
|
+
baseUrl: "https://api.example.mil/v2",
|
|
2904
|
+
specPath: "./specs/api.yaml"
|
|
2905
|
+
},
|
|
2906
|
+
null,
|
|
2907
|
+
2
|
|
2908
|
+
),
|
|
2909
|
+
data: JSON.stringify(
|
|
2910
|
+
{
|
|
2911
|
+
version: "1.0",
|
|
2912
|
+
generatedBy: "stackwright-pro-data-otter",
|
|
2913
|
+
strategy: "<pulse-fast|isr-fast|isr-standard|isr-slow>",
|
|
2914
|
+
pulseMode: false,
|
|
2915
|
+
collections: [{ name: "equipment", revalidate: 60, pulse: false }],
|
|
2916
|
+
endpoints: { included: ["/equipment/**"], excluded: ["/admin/**"] },
|
|
2917
|
+
requiredPackages: { dependencies: {}, devPackages: {} }
|
|
2918
|
+
},
|
|
2919
|
+
null,
|
|
2920
|
+
2
|
|
2921
|
+
),
|
|
2922
|
+
geo: JSON.stringify(
|
|
2923
|
+
{
|
|
2924
|
+
version: "1.0",
|
|
2925
|
+
generatedBy: "stackwright-pro-geo-otter",
|
|
2926
|
+
geoCollections: [
|
|
2927
|
+
{
|
|
2928
|
+
collection: "vessels",
|
|
2929
|
+
latField: "latitude",
|
|
2930
|
+
lngField: "longitude",
|
|
2931
|
+
labelField: "vesselName",
|
|
2932
|
+
colorField: "navigationStatus"
|
|
2933
|
+
}
|
|
2934
|
+
],
|
|
2935
|
+
pages: [
|
|
2936
|
+
{
|
|
2937
|
+
slug: "fleet-tracker",
|
|
2938
|
+
type: "<tracker|zone|route|combined>",
|
|
2939
|
+
collections: ["vessels"],
|
|
2940
|
+
hasLayers: false
|
|
2941
|
+
}
|
|
2942
|
+
]
|
|
2943
|
+
},
|
|
2944
|
+
null,
|
|
2945
|
+
2
|
|
2946
|
+
),
|
|
2947
|
+
workflow: JSON.stringify(
|
|
2948
|
+
{
|
|
2949
|
+
version: "1.0",
|
|
2950
|
+
generatedBy: "stackwright-pro-workflow-otter",
|
|
2951
|
+
workflowConfig: {
|
|
2952
|
+
id: "procurement-approval",
|
|
2953
|
+
route: "/procurement",
|
|
2954
|
+
files: ["workflows/procurement-approval.yml"],
|
|
2955
|
+
serviceDependencies: ["service:workflow-state"],
|
|
2956
|
+
warnings: []
|
|
2957
|
+
}
|
|
2958
|
+
},
|
|
2959
|
+
null,
|
|
2960
|
+
2
|
|
2961
|
+
),
|
|
2962
|
+
services: JSON.stringify(
|
|
2963
|
+
{
|
|
2964
|
+
version: "1.0",
|
|
2965
|
+
generatedBy: "stackwright-services-otter",
|
|
2966
|
+
flows: [
|
|
2967
|
+
{
|
|
2968
|
+
name: "at-risk-patients",
|
|
2969
|
+
trigger: "<http|event|schedule|queue>",
|
|
2970
|
+
steps: 3,
|
|
2971
|
+
outputSpec: "at-risk-patients.openapi.json"
|
|
2972
|
+
}
|
|
2973
|
+
],
|
|
2974
|
+
workflows: [
|
|
2975
|
+
{
|
|
2976
|
+
name: "evacuation-coordination",
|
|
2977
|
+
states: 4,
|
|
2978
|
+
transitions: 5
|
|
2979
|
+
}
|
|
2980
|
+
],
|
|
2981
|
+
openApiSpecs: ["at-risk-patients.openapi.json"],
|
|
2982
|
+
capabilitiesUsed: ["service.call", "collection.join", "collection.filter"]
|
|
2983
|
+
},
|
|
2984
|
+
null,
|
|
2985
|
+
2
|
|
2986
|
+
),
|
|
2987
|
+
pages: JSON.stringify(
|
|
2988
|
+
{
|
|
2989
|
+
version: "1.0",
|
|
2990
|
+
generatedBy: "stackwright-pro-page-otter",
|
|
2991
|
+
pages: [
|
|
2992
|
+
{
|
|
2993
|
+
slug: "catalog",
|
|
2994
|
+
type: "collection_listing",
|
|
2995
|
+
collection: "products",
|
|
2996
|
+
themeApplied: true,
|
|
2997
|
+
authRequired: false
|
|
2998
|
+
},
|
|
2999
|
+
{
|
|
3000
|
+
slug: "admin",
|
|
3001
|
+
type: "protected",
|
|
3002
|
+
collection: null,
|
|
3003
|
+
themeApplied: true,
|
|
3004
|
+
authRequired: true
|
|
3005
|
+
}
|
|
3006
|
+
]
|
|
3007
|
+
},
|
|
3008
|
+
null,
|
|
3009
|
+
2
|
|
3010
|
+
),
|
|
3011
|
+
dashboard: JSON.stringify(
|
|
3012
|
+
{
|
|
3013
|
+
version: "1.0",
|
|
3014
|
+
generatedBy: "stackwright-pro-dashboard-otter",
|
|
3015
|
+
pages: [
|
|
3016
|
+
{
|
|
3017
|
+
slug: "dashboard",
|
|
3018
|
+
layout: "<grid|table|mixed>",
|
|
3019
|
+
collections: ["equipment", "supplies"],
|
|
3020
|
+
mode: "<ISR|Pulse>"
|
|
3021
|
+
}
|
|
3022
|
+
]
|
|
3023
|
+
},
|
|
3024
|
+
null,
|
|
3025
|
+
2
|
|
3026
|
+
),
|
|
3027
|
+
// type: 'pki' = CAC/DoD certificate auth | 'oidc' = enterprise SSO
|
|
3028
|
+
// For dev-only mock auth: use type: 'oidc' with devOnly: true (Zod strips devOnly — it's a convention only)
|
|
3029
|
+
auth: JSON.stringify(
|
|
3030
|
+
{
|
|
3031
|
+
version: "1.0",
|
|
3032
|
+
generatedBy: "stackwright-pro-auth-otter",
|
|
3033
|
+
authConfig: {
|
|
3034
|
+
type: "<pki|oidc>",
|
|
3035
|
+
// OIDC-only fields (omit for pki):
|
|
3036
|
+
provider: "<azure_ad|okta|cognito|auth0|authentik|keycloak|custom>",
|
|
3037
|
+
discoveryUrl: "<IdP OIDC discovery URL>",
|
|
3038
|
+
clientId: "<OIDC client ID>",
|
|
3039
|
+
clientSecret: "<OIDC client secret>",
|
|
3040
|
+
rbacRoles: ["ADMIN", "ANALYST"],
|
|
3041
|
+
rbacDefaultRole: "ANALYST",
|
|
3042
|
+
protectedRoutes: ["/dashboard/:path*", "/procurement/:path*"],
|
|
3043
|
+
auditEnabled: true,
|
|
3044
|
+
auditRetentionDays: 90
|
|
3045
|
+
}
|
|
3046
|
+
},
|
|
3047
|
+
null,
|
|
3048
|
+
2
|
|
3049
|
+
),
|
|
3050
|
+
polish: JSON.stringify(
|
|
3051
|
+
{
|
|
3052
|
+
version: "1.0",
|
|
3053
|
+
generatedBy: "stackwright-pro-polish-otter",
|
|
3054
|
+
landingPage: { slug: "", rewritten: true },
|
|
3055
|
+
navigation: { itemCount: 5, items: ["/dashboard", "/equipment", "/workflows"] },
|
|
3056
|
+
gettingStarted: "<rewritten|redirected|not-found>"
|
|
3057
|
+
},
|
|
3058
|
+
null,
|
|
3059
|
+
2
|
|
3060
|
+
)
|
|
2012
3061
|
};
|
|
2013
3062
|
function handleValidateArtifact(input) {
|
|
2014
3063
|
const cwd = input._cwd ?? process.cwd();
|
|
2015
|
-
const { phase, responseText } = input;
|
|
3064
|
+
const { phase, responseText, artifact: directArtifact } = input;
|
|
2016
3065
|
if (!isValidPhase(phase)) {
|
|
2017
3066
|
return {
|
|
2018
3067
|
text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),
|
|
2019
3068
|
isError: true
|
|
2020
3069
|
};
|
|
2021
3070
|
}
|
|
2022
|
-
|
|
2023
|
-
|
|
3071
|
+
let artifact;
|
|
3072
|
+
if (directArtifact) {
|
|
3073
|
+
artifact = directArtifact;
|
|
3074
|
+
} else {
|
|
3075
|
+
const text = responseText ?? "";
|
|
3076
|
+
for (const { pattern, label } of OFF_SCRIPT_PATTERNS) {
|
|
3077
|
+
if (pattern.test(text)) {
|
|
3078
|
+
const result = {
|
|
3079
|
+
valid: false,
|
|
3080
|
+
phase,
|
|
3081
|
+
violation: "off-script",
|
|
3082
|
+
retryPrompt: `You returned code output (detected: ${label}). Return ONLY a JSON artifact \u2014 no code, no files.`
|
|
3083
|
+
};
|
|
3084
|
+
return { text: JSON.stringify(result), isError: false };
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
try {
|
|
3088
|
+
artifact = extractJsonFromResponse(text);
|
|
3089
|
+
} catch {
|
|
2024
3090
|
const result = {
|
|
2025
3091
|
valid: false,
|
|
2026
3092
|
phase,
|
|
2027
|
-
violation: "
|
|
2028
|
-
retryPrompt:
|
|
3093
|
+
violation: "invalid-json",
|
|
3094
|
+
retryPrompt: "Your response did not contain valid JSON. Return a single JSON object with no surrounding text."
|
|
2029
3095
|
};
|
|
2030
3096
|
return { text: JSON.stringify(result), isError: false };
|
|
2031
3097
|
}
|
|
2032
3098
|
}
|
|
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
|
-
}
|
|
2045
3099
|
if (!artifact.version || !artifact.generatedBy) {
|
|
2046
3100
|
const result = {
|
|
2047
3101
|
valid: false,
|
|
@@ -2062,12 +3116,58 @@ function handleValidateArtifact(input) {
|
|
|
2062
3116
|
};
|
|
2063
3117
|
return { text: JSON.stringify(result), isError: false };
|
|
2064
3118
|
}
|
|
3119
|
+
const PHASE_ZOD_VALIDATORS = {
|
|
3120
|
+
workflow: (artifact2) => {
|
|
3121
|
+
const workflowConfig = artifact2["workflowConfig"];
|
|
3122
|
+
if (!workflowConfig) return { success: true };
|
|
3123
|
+
const result = WorkflowFileSchema.safeParse(workflowConfig);
|
|
3124
|
+
if (!result.success) {
|
|
3125
|
+
const issues = result.error.issues.slice(0, 3).map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
3126
|
+
return { success: false, error: { message: issues } };
|
|
3127
|
+
}
|
|
3128
|
+
return { success: true };
|
|
3129
|
+
},
|
|
3130
|
+
auth: (artifact2) => {
|
|
3131
|
+
const authConfig = artifact2["authConfig"];
|
|
3132
|
+
if (!authConfig) return { success: true };
|
|
3133
|
+
const result = authConfigSchema.safeParse(authConfig);
|
|
3134
|
+
if (!result.success) {
|
|
3135
|
+
const issues = result.error.issues.slice(0, 3).map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
3136
|
+
return { success: false, error: { message: issues } };
|
|
3137
|
+
}
|
|
3138
|
+
return { success: true };
|
|
3139
|
+
}
|
|
3140
|
+
};
|
|
3141
|
+
const zodValidator = PHASE_ZOD_VALIDATORS[phase];
|
|
3142
|
+
if (zodValidator) {
|
|
3143
|
+
const zodResult = zodValidator(artifact);
|
|
3144
|
+
if (!zodResult.success) {
|
|
3145
|
+
const result = {
|
|
3146
|
+
valid: false,
|
|
3147
|
+
phase,
|
|
3148
|
+
violation: "schema-mismatch",
|
|
3149
|
+
retryPrompt: `Your artifact failed schema validation: ${zodResult.error?.message}. Fix these fields and return the corrected JSON artifact.`
|
|
3150
|
+
};
|
|
3151
|
+
return { text: JSON.stringify(result), isError: false };
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
2065
3154
|
try {
|
|
2066
|
-
const artifactsDir =
|
|
2067
|
-
|
|
3155
|
+
const artifactsDir = join4(cwd, ".stackwright", "artifacts");
|
|
3156
|
+
mkdirSync4(artifactsDir, { recursive: true });
|
|
2068
3157
|
const artifactFile = PHASE_ARTIFACT[phase];
|
|
2069
|
-
const artifactPath =
|
|
2070
|
-
|
|
3158
|
+
const artifactPath = join4(artifactsDir, artifactFile);
|
|
3159
|
+
const serialized = JSON.stringify(artifact, null, 2) + "\n";
|
|
3160
|
+
const artifactBytes = Buffer.from(serialized, "utf-8");
|
|
3161
|
+
safeWriteSync(artifactPath, serialized);
|
|
3162
|
+
let signed = false;
|
|
3163
|
+
try {
|
|
3164
|
+
const { privateKey } = loadPipelineKeys(cwd);
|
|
3165
|
+
const sig = signArtifact(artifactBytes, privateKey);
|
|
3166
|
+
const signerOtter = PHASE_TO_OTTER2[phase];
|
|
3167
|
+
saveArtifactSignature(cwd, artifactFile, sig, signerOtter);
|
|
3168
|
+
signed = true;
|
|
3169
|
+
} catch {
|
|
3170
|
+
}
|
|
2071
3171
|
const state = readState(cwd);
|
|
2072
3172
|
if (!state.phases[phase]) state.phases[phase] = defaultPhaseStatus();
|
|
2073
3173
|
const ps = state.phases[phase];
|
|
@@ -2078,7 +3178,7 @@ function handleValidateArtifact(input) {
|
|
|
2078
3178
|
valid: true,
|
|
2079
3179
|
phase,
|
|
2080
3180
|
artifactPath,
|
|
2081
|
-
summary: `Wrote ${artifactFile} (keys: ${topKeys}${Object.keys(artifact).length > 5 ? ", ..." : ""})`
|
|
3181
|
+
summary: `Wrote ${artifactFile} (keys: ${topKeys}${Object.keys(artifact).length > 5 ? ", ..." : ""})${signed ? " [signed]" : ""}`
|
|
2082
3182
|
};
|
|
2083
3183
|
return { text: JSON.stringify(result), isError: false };
|
|
2084
3184
|
} catch (err) {
|
|
@@ -2102,11 +3202,15 @@ function registerPipelineTools(server2) {
|
|
|
2102
3202
|
"stackwright_pro_set_pipeline_state",
|
|
2103
3203
|
`Atomic read\u2192modify\u2192write pipeline state. ${DESC}`,
|
|
2104
3204
|
{
|
|
2105
|
-
phase:
|
|
2106
|
-
field:
|
|
2107
|
-
value:
|
|
2108
|
-
|
|
2109
|
-
|
|
3205
|
+
phase: z11.string().optional().describe('Phase to update, e.g. "designer"'),
|
|
3206
|
+
field: z11.enum(["questionsCollected", "answered", "executed", "artifactWritten"]).optional().describe("Boolean field to set"),
|
|
3207
|
+
value: boolCoerce(z11.boolean().optional()).describe(
|
|
3208
|
+
'Value for the field \u2014 must be a JSON boolean (true/false), NOT the string "true"/"false"'
|
|
3209
|
+
),
|
|
3210
|
+
status: z11.enum(["setup", "questions", "execution", "done"]).optional().describe("Top-level status override"),
|
|
3211
|
+
incrementRetry: boolCoerce(z11.boolean().optional()).describe(
|
|
3212
|
+
"Bump retryCount by 1 \u2014 must be a JSON boolean"
|
|
3213
|
+
)
|
|
2110
3214
|
},
|
|
2111
3215
|
async (args) => res(
|
|
2112
3216
|
handleSetPipelineState({
|
|
@@ -2120,9 +3224,17 @@ function registerPipelineTools(server2) {
|
|
|
2120
3224
|
);
|
|
2121
3225
|
server2.tool(
|
|
2122
3226
|
"stackwright_pro_check_execution_ready",
|
|
2123
|
-
`Check all phases have answer files in .stackwright/answers/. ${DESC}`,
|
|
3227
|
+
`Check all phases have answer files in .stackwright/answers/. If phase is provided, check only that phase. ${DESC}`,
|
|
3228
|
+
{
|
|
3229
|
+
phase: z11.string().optional().describe("If provided, check only this phase's readiness. Omit to check all phases.")
|
|
3230
|
+
},
|
|
3231
|
+
async ({ phase }) => res(handleCheckExecutionReady(void 0, phase))
|
|
3232
|
+
);
|
|
3233
|
+
server2.tool(
|
|
3234
|
+
"stackwright_pro_get_ready_phases",
|
|
3235
|
+
`Return phases whose dependencies are all satisfied (executed=true). Use to determine which phases can run next \u2014 enables wave-based execution. ${DESC}`,
|
|
2124
3236
|
{},
|
|
2125
|
-
async () => res(
|
|
3237
|
+
async () => res(handleGetReadyPhases())
|
|
2126
3238
|
);
|
|
2127
3239
|
server2.tool(
|
|
2128
3240
|
"stackwright_pro_list_artifacts",
|
|
@@ -2132,40 +3244,92 @@ function registerPipelineTools(server2) {
|
|
|
2132
3244
|
);
|
|
2133
3245
|
server2.tool(
|
|
2134
3246
|
"stackwright_pro_write_phase_questions",
|
|
2135
|
-
`Parse otter question-collection response \u2192 .stackwright/questions/{phase}.json. ${DESC}`,
|
|
3247
|
+
`Parse otter question-collection response \u2192 .stackwright/questions/{phase}.json. Specialists may also call this directly with a parsed questions array. ${DESC}`,
|
|
2136
3248
|
{
|
|
2137
|
-
phase:
|
|
2138
|
-
responseText:
|
|
3249
|
+
phase: z11.string().optional().describe('Phase name, e.g. "designer" (required for direct write)'),
|
|
3250
|
+
responseText: z11.string().optional().describe("Raw LLM response from QUESTION_COLLECTION_MODE"),
|
|
3251
|
+
questions: jsonCoerce(z11.array(z11.any()).optional()).describe(
|
|
3252
|
+
"Questions array for direct specialist write"
|
|
3253
|
+
)
|
|
2139
3254
|
},
|
|
2140
|
-
async ({ phase, responseText }) =>
|
|
3255
|
+
async ({ phase, responseText, questions }) => {
|
|
3256
|
+
if (phase && questions && Array.isArray(questions)) {
|
|
3257
|
+
const SAFE_PHASE = /^[a-z][a-z0-9-]{0,30}$/;
|
|
3258
|
+
if (!SAFE_PHASE.test(phase)) {
|
|
3259
|
+
return {
|
|
3260
|
+
content: [
|
|
3261
|
+
{ type: "text", text: JSON.stringify({ error: `Invalid phase name` }) }
|
|
3262
|
+
],
|
|
3263
|
+
isError: true
|
|
3264
|
+
};
|
|
3265
|
+
}
|
|
3266
|
+
const questionsDir = join4(process.cwd(), ".stackwright", "questions");
|
|
3267
|
+
mkdirSync4(questionsDir, { recursive: true });
|
|
3268
|
+
const outPath = join4(questionsDir, `${phase}.json`);
|
|
3269
|
+
writeFileSync4(
|
|
3270
|
+
outPath,
|
|
3271
|
+
JSON.stringify({ phase, questions, writtenAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)
|
|
3272
|
+
);
|
|
3273
|
+
return {
|
|
3274
|
+
content: [
|
|
3275
|
+
{
|
|
3276
|
+
type: "text",
|
|
3277
|
+
text: JSON.stringify({
|
|
3278
|
+
written: true,
|
|
3279
|
+
phase,
|
|
3280
|
+
count: questions.length
|
|
3281
|
+
})
|
|
3282
|
+
}
|
|
3283
|
+
]
|
|
3284
|
+
};
|
|
3285
|
+
}
|
|
3286
|
+
return res(
|
|
3287
|
+
handleWritePhaseQuestions({ phase: phase ?? "", responseText: responseText ?? "" })
|
|
3288
|
+
);
|
|
3289
|
+
}
|
|
2141
3290
|
);
|
|
2142
3291
|
server2.tool(
|
|
2143
3292
|
"stackwright_pro_build_specialist_prompt",
|
|
2144
3293
|
`Assemble execution prompt from answers + upstream artifacts. Foreman passes verbatim. ${DESC}`,
|
|
2145
|
-
{ phase:
|
|
3294
|
+
{ phase: z11.string().describe('Phase to build prompt for, e.g. "pages"') },
|
|
2146
3295
|
async ({ phase }) => res(handleBuildSpecialistPrompt({ phase }))
|
|
2147
3296
|
);
|
|
2148
3297
|
server2.tool(
|
|
2149
3298
|
"stackwright_pro_validate_artifact",
|
|
2150
|
-
`Validate
|
|
3299
|
+
`Validate and write artifact to .stackwright/artifacts/. Returns retryPrompt on failure. ${DESC}`,
|
|
2151
3300
|
{
|
|
2152
|
-
phase:
|
|
2153
|
-
responseText:
|
|
3301
|
+
phase: z11.string().describe('Phase that produced this artifact, e.g. "designer"'),
|
|
3302
|
+
responseText: z11.string().optional().describe("Raw response text from the specialist otter (Foreman-mediated path, legacy)"),
|
|
3303
|
+
artifact: jsonCoerce(z11.record(z11.string(), z11.unknown()).optional()).describe(
|
|
3304
|
+
"Artifact object to validate and write directly (specialist direct path \u2014 skips off-script detection and JSON parsing)"
|
|
3305
|
+
)
|
|
2154
3306
|
},
|
|
2155
|
-
async ({ phase, responseText }) =>
|
|
3307
|
+
async ({ phase, responseText, artifact }) => {
|
|
3308
|
+
if (artifact) {
|
|
3309
|
+
return res(
|
|
3310
|
+
handleValidateArtifact({ phase, artifact })
|
|
3311
|
+
);
|
|
3312
|
+
}
|
|
3313
|
+
return res(handleValidateArtifact({ phase, responseText: responseText ?? "" }));
|
|
3314
|
+
}
|
|
2156
3315
|
);
|
|
2157
3316
|
}
|
|
2158
3317
|
|
|
2159
3318
|
// src/tools/safe-write.ts
|
|
2160
|
-
import { z as
|
|
2161
|
-
import { writeFileSync as
|
|
2162
|
-
import { normalize, isAbsolute, dirname, join as
|
|
3319
|
+
import { z as z12 } from "zod";
|
|
3320
|
+
import { writeFileSync as writeFileSync5, existsSync as existsSync6, mkdirSync as mkdirSync5, lstatSync as lstatSync6 } from "fs";
|
|
3321
|
+
import { normalize, isAbsolute, dirname, join as join5 } from "path";
|
|
2163
3322
|
var OTTER_WRITE_ALLOWLISTS = {
|
|
2164
3323
|
"stackwright-pro-designer-otter": [
|
|
2165
3324
|
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Design language artifact" }
|
|
2166
3325
|
],
|
|
2167
3326
|
"stackwright-pro-theme-otter": [
|
|
2168
|
-
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Theme tokens artifact" }
|
|
3327
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Theme tokens artifact" },
|
|
3328
|
+
{
|
|
3329
|
+
prefix: "stackwright.theme.",
|
|
3330
|
+
suffix: ".yml",
|
|
3331
|
+
description: "Theme config YAML (merged at build time)"
|
|
3332
|
+
}
|
|
2169
3333
|
],
|
|
2170
3334
|
"stackwright-pro-auth-otter": [
|
|
2171
3335
|
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Auth config artifact" },
|
|
@@ -2198,13 +3362,54 @@ var OTTER_WRITE_ALLOWLISTS = {
|
|
|
2198
3362
|
],
|
|
2199
3363
|
"stackwright-pro-api-otter": [
|
|
2200
3364
|
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "API config artifact" }
|
|
3365
|
+
],
|
|
3366
|
+
"stackwright-pro-geo-otter": [
|
|
3367
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Geo manifest artifact" },
|
|
3368
|
+
{ prefix: "pages/", suffix: "/content.yml", description: "Map page content" },
|
|
3369
|
+
{ prefix: "pages/", suffix: "/content.yaml", description: "Map page content" }
|
|
3370
|
+
],
|
|
3371
|
+
"stackwright-pro-polish-otter": [
|
|
3372
|
+
{
|
|
3373
|
+
prefix: "stackwright.yml",
|
|
3374
|
+
suffix: "",
|
|
3375
|
+
description: "Stackwright config (navigation updates)"
|
|
3376
|
+
},
|
|
3377
|
+
{ prefix: "pages/", suffix: "/content.yml", description: "Landing page content" },
|
|
3378
|
+
{ prefix: "pages/", suffix: "/content.yaml", description: "Landing page content" },
|
|
3379
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Polish artifact" }
|
|
3380
|
+
],
|
|
3381
|
+
"stackwright-services-otter": [
|
|
3382
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Services config artifact" },
|
|
3383
|
+
{ prefix: "services/", suffix: ".ts", description: "Service implementation files" },
|
|
3384
|
+
{ prefix: "services/", suffix: ".yaml", description: "Service flow definitions" },
|
|
3385
|
+
{ prefix: "services/", suffix: ".yml", description: "Service flow definitions" },
|
|
3386
|
+
{ prefix: "lib/seeds/", suffix: ".ts", description: "Seed data files" },
|
|
3387
|
+
{ prefix: "specs/", suffix: ".json", description: "Generated OpenAPI specs" },
|
|
3388
|
+
{ prefix: "specs/", suffix: ".yaml", description: "Generated OpenAPI specs" },
|
|
3389
|
+
{ prefix: "stackwright-generated/", suffix: ".json", description: "Services manifest" }
|
|
2201
3390
|
]
|
|
2202
3391
|
};
|
|
2203
3392
|
var PROTECTED_PATH_PREFIXES = [
|
|
2204
3393
|
".stackwright/pipeline-state.json",
|
|
3394
|
+
".stackwright/pipeline-keys.json",
|
|
3395
|
+
// ephemeral signing keys
|
|
3396
|
+
".stackwright/artifacts/signatures.json",
|
|
3397
|
+
// artifact signature manifest
|
|
2205
3398
|
".stackwright/questions/",
|
|
2206
3399
|
".stackwright/answers/"
|
|
2207
3400
|
];
|
|
3401
|
+
var MAX_SAFE_WRITE_BYTES_JSON = 512 * 1024;
|
|
3402
|
+
var MAX_SAFE_WRITE_BYTES_YAML = 256 * 1024;
|
|
3403
|
+
var MAX_SAFE_WRITE_BYTES_ENV = 4 * 1024;
|
|
3404
|
+
var MAX_SAFE_WRITE_BYTES_DEFAULT = 256 * 1024;
|
|
3405
|
+
function getMaxBytesForPath(filePath) {
|
|
3406
|
+
if (filePath.endsWith(".json")) return { limit: MAX_SAFE_WRITE_BYTES_JSON, label: "JSON" };
|
|
3407
|
+
if (filePath.endsWith(".yml") || filePath.endsWith(".yaml"))
|
|
3408
|
+
return { limit: MAX_SAFE_WRITE_BYTES_YAML, label: "YAML" };
|
|
3409
|
+
if (filePath === ".env" || /^\.env\.[a-zA-Z0-9]+/.test(filePath))
|
|
3410
|
+
return { limit: MAX_SAFE_WRITE_BYTES_ENV, label: "env" };
|
|
3411
|
+
return { limit: MAX_SAFE_WRITE_BYTES_DEFAULT, label: "default" };
|
|
3412
|
+
}
|
|
2208
3413
|
function checkPathAllowed(callerOtter, filePath) {
|
|
2209
3414
|
const normalized = normalize(filePath);
|
|
2210
3415
|
if (normalized.includes("..")) {
|
|
@@ -2330,11 +3535,23 @@ function handleSafeWrite(input) {
|
|
|
2330
3535
|
};
|
|
2331
3536
|
return { text: JSON.stringify(result), isError: true };
|
|
2332
3537
|
}
|
|
3538
|
+
const contentBytes = Buffer.byteLength(content, "utf-8");
|
|
3539
|
+
const { limit: maxBytes, label: fileTypeLabel } = getMaxBytesForPath(filePath);
|
|
3540
|
+
if (contentBytes > maxBytes) {
|
|
3541
|
+
const result = {
|
|
3542
|
+
success: false,
|
|
3543
|
+
error: `Content size ${contentBytes} bytes exceeds ${fileTypeLabel} limit of ${maxBytes} bytes (${maxBytes / 1024} KB)`,
|
|
3544
|
+
callerOtter,
|
|
3545
|
+
attemptedPath: filePath,
|
|
3546
|
+
allowedPaths: []
|
|
3547
|
+
};
|
|
3548
|
+
return { text: JSON.stringify(result), isError: true };
|
|
3549
|
+
}
|
|
2333
3550
|
const normalized = normalize(filePath);
|
|
2334
|
-
const fullPath =
|
|
2335
|
-
if (
|
|
3551
|
+
const fullPath = join5(cwd, normalized);
|
|
3552
|
+
if (existsSync6(fullPath)) {
|
|
2336
3553
|
try {
|
|
2337
|
-
const stat =
|
|
3554
|
+
const stat = lstatSync6(fullPath);
|
|
2338
3555
|
if (stat.isSymbolicLink()) {
|
|
2339
3556
|
const result = {
|
|
2340
3557
|
success: false,
|
|
@@ -2361,9 +3578,9 @@ function handleSafeWrite(input) {
|
|
|
2361
3578
|
}
|
|
2362
3579
|
try {
|
|
2363
3580
|
if (createDirectories) {
|
|
2364
|
-
|
|
3581
|
+
mkdirSync5(dirname(fullPath), { recursive: true });
|
|
2365
3582
|
}
|
|
2366
|
-
|
|
3583
|
+
writeFileSync5(fullPath, content, { encoding: "utf-8" });
|
|
2367
3584
|
const result = {
|
|
2368
3585
|
success: true,
|
|
2369
3586
|
path: normalized,
|
|
@@ -2389,10 +3606,12 @@ function registerSafeWriteTools(server2) {
|
|
|
2389
3606
|
"stackwright_pro_safe_write",
|
|
2390
3607
|
DESC,
|
|
2391
3608
|
{
|
|
2392
|
-
callerOtter:
|
|
2393
|
-
filePath:
|
|
2394
|
-
content:
|
|
2395
|
-
createDirectories:
|
|
3609
|
+
callerOtter: z12.string().describe('The otter agent name requesting the write, e.g. "stackwright-pro-page-otter"'),
|
|
3610
|
+
filePath: z12.string().describe('Relative path from project root, e.g. "pages/dashboard/content.yml"'),
|
|
3611
|
+
content: z12.string().describe("File content to write"),
|
|
3612
|
+
createDirectories: boolCoerce(z12.boolean().optional().default(true)).describe(
|
|
3613
|
+
"Create parent directories if they don't exist. Default: true"
|
|
3614
|
+
)
|
|
2396
3615
|
},
|
|
2397
3616
|
async ({ callerOtter, filePath, content, createDirectories }) => {
|
|
2398
3617
|
const result = handleSafeWrite({
|
|
@@ -2407,9 +3626,9 @@ function registerSafeWriteTools(server2) {
|
|
|
2407
3626
|
}
|
|
2408
3627
|
|
|
2409
3628
|
// src/tools/auth.ts
|
|
2410
|
-
import { z as
|
|
2411
|
-
import { readFileSync as
|
|
2412
|
-
import { join as
|
|
3629
|
+
import { z as z13 } from "zod";
|
|
3630
|
+
import { readFileSync as readFileSync5, writeFileSync as writeFileSync6, existsSync as existsSync7 } from "fs";
|
|
3631
|
+
import { join as join6 } from "path";
|
|
2413
3632
|
function buildHierarchy(roles) {
|
|
2414
3633
|
const h = {};
|
|
2415
3634
|
for (let i = 0; i < roles.length - 1; i++) {
|
|
@@ -2671,7 +3890,7 @@ async function configureAuthHandler(params, cwd) {
|
|
|
2671
3890
|
auditRetentionDays,
|
|
2672
3891
|
protectedRoutes
|
|
2673
3892
|
);
|
|
2674
|
-
|
|
3893
|
+
writeFileSync6(join6(cwd, "middleware.ts"), middlewareContent, "utf8");
|
|
2675
3894
|
filesWritten.push("middleware.ts");
|
|
2676
3895
|
} catch (err) {
|
|
2677
3896
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -2687,12 +3906,12 @@ async function configureAuthHandler(params, cwd) {
|
|
|
2687
3906
|
}
|
|
2688
3907
|
try {
|
|
2689
3908
|
const envBlock = generateEnvBlock(method, params);
|
|
2690
|
-
const envPath =
|
|
2691
|
-
if (
|
|
2692
|
-
const existing =
|
|
2693
|
-
|
|
3909
|
+
const envPath = join6(cwd, ".env.example");
|
|
3910
|
+
if (existsSync7(envPath)) {
|
|
3911
|
+
const existing = readFileSync5(envPath, "utf8");
|
|
3912
|
+
writeFileSync6(envPath, existing.trimEnd() + "\n\n" + envBlock, "utf8");
|
|
2694
3913
|
} else {
|
|
2695
|
-
|
|
3914
|
+
writeFileSync6(envPath, envBlock, "utf8");
|
|
2696
3915
|
}
|
|
2697
3916
|
filesWritten.push(".env.example");
|
|
2698
3917
|
} catch (err) {
|
|
@@ -2718,12 +3937,12 @@ async function configureAuthHandler(params, cwd) {
|
|
|
2718
3937
|
auditRetentionDays,
|
|
2719
3938
|
protectedRoutes
|
|
2720
3939
|
);
|
|
2721
|
-
const ymlPath =
|
|
2722
|
-
if (!
|
|
2723
|
-
|
|
3940
|
+
const ymlPath = join6(cwd, "stackwright.yml");
|
|
3941
|
+
if (!existsSync7(ymlPath)) {
|
|
3942
|
+
writeFileSync6(ymlPath, authYaml, "utf8");
|
|
2724
3943
|
} else {
|
|
2725
|
-
const existing =
|
|
2726
|
-
|
|
3944
|
+
const existing = readFileSync5(ymlPath, "utf8");
|
|
3945
|
+
writeFileSync6(ymlPath, upsertAuthBlock(existing, authYaml), "utf8");
|
|
2727
3946
|
}
|
|
2728
3947
|
filesWritten.push("stackwright.yml");
|
|
2729
3948
|
} catch (err) {
|
|
@@ -2762,35 +3981,35 @@ function registerAuthTools(server2) {
|
|
|
2762
3981
|
"stackwright_pro_configure_auth",
|
|
2763
3982
|
"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
3983
|
{
|
|
2765
|
-
method:
|
|
2766
|
-
provider:
|
|
3984
|
+
method: z13.enum(["cac", "oidc", "oauth2", "none"]),
|
|
3985
|
+
provider: z13.enum(["azure-ad", "okta", "ping", "cognito", "custom"]).optional(),
|
|
2767
3986
|
// CAC
|
|
2768
|
-
cacCaBundle:
|
|
2769
|
-
cacEdipiLookup:
|
|
2770
|
-
cacOcspEndpoint:
|
|
2771
|
-
cacCertHeader:
|
|
3987
|
+
cacCaBundle: z13.string().optional(),
|
|
3988
|
+
cacEdipiLookup: z13.string().optional(),
|
|
3989
|
+
cacOcspEndpoint: z13.string().optional(),
|
|
3990
|
+
cacCertHeader: z13.string().optional(),
|
|
2772
3991
|
// OIDC
|
|
2773
|
-
oidcDiscoveryUrl:
|
|
2774
|
-
oidcClientId:
|
|
2775
|
-
oidcClientSecret:
|
|
2776
|
-
oidcScopes:
|
|
2777
|
-
oidcRoleClaim:
|
|
3992
|
+
oidcDiscoveryUrl: z13.string().optional(),
|
|
3993
|
+
oidcClientId: z13.string().optional(),
|
|
3994
|
+
oidcClientSecret: z13.string().optional(),
|
|
3995
|
+
oidcScopes: z13.string().optional(),
|
|
3996
|
+
oidcRoleClaim: z13.string().optional(),
|
|
2778
3997
|
// OAuth2
|
|
2779
|
-
oauth2AuthUrl:
|
|
2780
|
-
oauth2TokenUrl:
|
|
2781
|
-
oauth2ClientId:
|
|
2782
|
-
oauth2ClientSecret:
|
|
2783
|
-
oauth2Scopes:
|
|
3998
|
+
oauth2AuthUrl: z13.string().optional(),
|
|
3999
|
+
oauth2TokenUrl: z13.string().optional(),
|
|
4000
|
+
oauth2ClientId: z13.string().optional(),
|
|
4001
|
+
oauth2ClientSecret: z13.string().optional(),
|
|
4002
|
+
oauth2Scopes: z13.string().optional(),
|
|
2784
4003
|
// RBAC
|
|
2785
|
-
rbacRoles:
|
|
2786
|
-
rbacDefaultRole:
|
|
4004
|
+
rbacRoles: jsonCoerce(z13.array(z13.string()).optional()),
|
|
4005
|
+
rbacDefaultRole: z13.string().optional(),
|
|
2787
4006
|
// Audit
|
|
2788
|
-
auditEnabled:
|
|
2789
|
-
auditRetentionDays:
|
|
4007
|
+
auditEnabled: boolCoerce(z13.boolean().optional()),
|
|
4008
|
+
auditRetentionDays: numCoerce(z13.number().int().positive().optional()),
|
|
2790
4009
|
// Routes
|
|
2791
|
-
protectedRoutes:
|
|
4010
|
+
protectedRoutes: jsonCoerce(z13.array(z13.string()).optional()),
|
|
2792
4011
|
// Injection for tests
|
|
2793
|
-
_cwd:
|
|
4012
|
+
_cwd: z13.string().optional()
|
|
2794
4013
|
},
|
|
2795
4014
|
async (params) => {
|
|
2796
4015
|
const cwd = params._cwd ?? process.cwd();
|
|
@@ -2800,45 +4019,61 @@ function registerAuthTools(server2) {
|
|
|
2800
4019
|
}
|
|
2801
4020
|
|
|
2802
4021
|
// src/integrity.ts
|
|
2803
|
-
import { createHash as
|
|
2804
|
-
import { readFileSync as
|
|
2805
|
-
import { join as
|
|
4022
|
+
import { createHash as createHash4, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
4023
|
+
import { readFileSync as readFileSync6, readdirSync as readdirSync2, lstatSync as lstatSync7 } from "fs";
|
|
4024
|
+
import { join as join7, basename } from "path";
|
|
2806
4025
|
var _checksums = /* @__PURE__ */ new Map([
|
|
2807
4026
|
[
|
|
2808
4027
|
"stackwright-pro-api-otter.json",
|
|
2809
|
-
"
|
|
4028
|
+
"9fbaed0ce6116b82d0289f24432037d04637c89b8e73062ed946e5d49b294734"
|
|
2810
4029
|
],
|
|
2811
4030
|
[
|
|
2812
4031
|
"stackwright-pro-auth-otter.json",
|
|
2813
|
-
"
|
|
4032
|
+
"8a6ee02cfe7fede3ca708d05b8b46824eb71f60c7f474b6edf9599da77f779b2"
|
|
2814
4033
|
],
|
|
2815
4034
|
[
|
|
2816
4035
|
"stackwright-pro-dashboard-otter.json",
|
|
2817
|
-
"
|
|
4036
|
+
"f5a83b74ad7c44edc6f39b45a568fa122d82aa4788f741ce14614da56d4e29a4"
|
|
2818
4037
|
],
|
|
2819
4038
|
[
|
|
2820
4039
|
"stackwright-pro-data-otter.json",
|
|
2821
|
-
"
|
|
4040
|
+
"c406e1c775bcb1f2b038b40a92d9bd23172b40d774fc0fa50bad4c9714f53445"
|
|
2822
4041
|
],
|
|
2823
4042
|
[
|
|
2824
4043
|
"stackwright-pro-designer-otter.json",
|
|
2825
|
-
"
|
|
4044
|
+
"af09ac8f06385bdbac63e2820daa2ff7d38b8ff1ff383c161f07e3fb9d9359c5"
|
|
4045
|
+
],
|
|
4046
|
+
[
|
|
4047
|
+
"stackwright-pro-domain-expert-otter.json",
|
|
4048
|
+
"bfe5c167d73fef3f2ef280fff56dcb552073c218e1394a43ecf983a03169ed55"
|
|
2826
4049
|
],
|
|
2827
4050
|
[
|
|
2828
4051
|
"stackwright-pro-foreman-otter.json",
|
|
2829
|
-
"
|
|
4052
|
+
"ab38ef53b95ec610a38b2866d78a135cbec16d257a9b35d7e46e2fee2d4de235"
|
|
4053
|
+
],
|
|
4054
|
+
[
|
|
4055
|
+
"stackwright-pro-geo-otter.json",
|
|
4056
|
+
"6eb7ecf97254dbd79c09ad24348bf16001423cce9585c14bef81afd67b7b901b"
|
|
2830
4057
|
],
|
|
2831
4058
|
[
|
|
2832
4059
|
"stackwright-pro-page-otter.json",
|
|
2833
|
-
"
|
|
4060
|
+
"9a5672f0758c81539337d86955e2892cd412547b4f111c2aa098eed1e62d7626"
|
|
4061
|
+
],
|
|
4062
|
+
[
|
|
4063
|
+
"stackwright-pro-polish-otter.json",
|
|
4064
|
+
"d31116995fdb417798af6056efd03bb1c71e0891371aba1774d283c03c9d77e8"
|
|
2834
4065
|
],
|
|
2835
4066
|
[
|
|
2836
4067
|
"stackwright-pro-theme-otter.json",
|
|
2837
|
-
"
|
|
4068
|
+
"08bb04009fdfb8743b10ac4d503cbaddaf8d7c804ba9b606aaed9cc516fd8e93"
|
|
2838
4069
|
],
|
|
2839
4070
|
[
|
|
2840
4071
|
"stackwright-pro-workflow-otter.json",
|
|
2841
|
-
"
|
|
4072
|
+
"c90d6773b2287aa9a640c2715ca0e75f44c13e99fddcfb89ced36603f38930ce"
|
|
4073
|
+
],
|
|
4074
|
+
[
|
|
4075
|
+
"stackwright-services-otter.json",
|
|
4076
|
+
"4893a596d187110124f78336ee91184a51b3c8d980c455382fe481adb9b487b5"
|
|
2842
4077
|
]
|
|
2843
4078
|
]);
|
|
2844
4079
|
Object.freeze(_checksums);
|
|
@@ -2853,11 +4088,11 @@ for (const [name, digest] of CANONICAL_CHECKSUMS) {
|
|
|
2853
4088
|
}
|
|
2854
4089
|
var MAX_OTTER_BYTES = 1 * 1024 * 1024;
|
|
2855
4090
|
function computeSha256(data) {
|
|
2856
|
-
return
|
|
4091
|
+
return createHash4("sha256").update(data).digest("hex");
|
|
2857
4092
|
}
|
|
2858
4093
|
function safeEqual(a, b) {
|
|
2859
4094
|
if (a.length !== b.length) return false;
|
|
2860
|
-
return
|
|
4095
|
+
return timingSafeEqual2(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
2861
4096
|
}
|
|
2862
4097
|
function verifyOtterFile(filePath) {
|
|
2863
4098
|
const filename = basename(filePath);
|
|
@@ -2867,7 +4102,7 @@ function verifyOtterFile(filePath) {
|
|
|
2867
4102
|
}
|
|
2868
4103
|
let stat;
|
|
2869
4104
|
try {
|
|
2870
|
-
stat =
|
|
4105
|
+
stat = lstatSync7(filePath);
|
|
2871
4106
|
} catch (err) {
|
|
2872
4107
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2873
4108
|
return { verified: false, filename, error: `Cannot stat file: ${msg}` };
|
|
@@ -2885,7 +4120,7 @@ function verifyOtterFile(filePath) {
|
|
|
2885
4120
|
}
|
|
2886
4121
|
let raw;
|
|
2887
4122
|
try {
|
|
2888
|
-
raw =
|
|
4123
|
+
raw = readFileSync6(filePath);
|
|
2889
4124
|
} catch (err) {
|
|
2890
4125
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2891
4126
|
return { verified: false, filename, error: `Cannot read file: ${msg}` };
|
|
@@ -2918,12 +4153,24 @@ function verifyOtterFile(filePath) {
|
|
|
2918
4153
|
return { verified: true, filename };
|
|
2919
4154
|
}
|
|
2920
4155
|
function verifyAllOtters(otterDir) {
|
|
4156
|
+
if (/(?:^|[/\\])\.\.(?:[/\\]|$)/.test(otterDir) || otterDir.includes("..")) {
|
|
4157
|
+
return {
|
|
4158
|
+
verified: [],
|
|
4159
|
+
failed: [
|
|
4160
|
+
{
|
|
4161
|
+
filename: "<directory>",
|
|
4162
|
+
error: `Security: path traversal sequence detected in otter directory parameter`
|
|
4163
|
+
}
|
|
4164
|
+
],
|
|
4165
|
+
unknown: []
|
|
4166
|
+
};
|
|
4167
|
+
}
|
|
2921
4168
|
const verified = [];
|
|
2922
4169
|
const failed = [];
|
|
2923
4170
|
const unknown = [];
|
|
2924
4171
|
let entries;
|
|
2925
4172
|
try {
|
|
2926
|
-
entries =
|
|
4173
|
+
entries = readdirSync2(otterDir);
|
|
2927
4174
|
} catch (err) {
|
|
2928
4175
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2929
4176
|
return {
|
|
@@ -2934,9 +4181,9 @@ function verifyAllOtters(otterDir) {
|
|
|
2934
4181
|
}
|
|
2935
4182
|
const otterFiles = entries.filter((f) => f.endsWith("-otter.json"));
|
|
2936
4183
|
for (const filename of otterFiles) {
|
|
2937
|
-
const filePath =
|
|
4184
|
+
const filePath = join7(otterDir, filename);
|
|
2938
4185
|
try {
|
|
2939
|
-
if (
|
|
4186
|
+
if (lstatSync7(filePath).isSymbolicLink()) {
|
|
2940
4187
|
failed.push({ filename, error: "Skipped: symlink" });
|
|
2941
4188
|
continue;
|
|
2942
4189
|
}
|
|
@@ -2962,15 +4209,30 @@ var DEFAULT_SEARCH_PATHS = ["node_modules/@stackwright-pro/otters/src/", "packag
|
|
|
2962
4209
|
function resolveOtterDir() {
|
|
2963
4210
|
const cwd = process.cwd();
|
|
2964
4211
|
for (const relative of DEFAULT_SEARCH_PATHS) {
|
|
2965
|
-
const candidate =
|
|
4212
|
+
const candidate = join7(cwd, relative);
|
|
2966
4213
|
try {
|
|
2967
|
-
|
|
4214
|
+
lstatSync7(candidate);
|
|
2968
4215
|
return candidate;
|
|
2969
4216
|
} catch {
|
|
2970
4217
|
}
|
|
2971
4218
|
}
|
|
2972
4219
|
return null;
|
|
2973
4220
|
}
|
|
4221
|
+
function emitIntegrityAuditEvent(params) {
|
|
4222
|
+
const record = JSON.stringify({
|
|
4223
|
+
level: "AUDIT",
|
|
4224
|
+
event: "INTEGRITY_FAIL",
|
|
4225
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4226
|
+
source: "stackwright_pro_verify_otter_integrity",
|
|
4227
|
+
otterDir: params.otterDir,
|
|
4228
|
+
failedCount: params.failed.length,
|
|
4229
|
+
unknownCount: params.unknown.length,
|
|
4230
|
+
failures: params.failed,
|
|
4231
|
+
unknown: params.unknown
|
|
4232
|
+
});
|
|
4233
|
+
process.stderr.write(`INTEGRITY_FAIL ${record}
|
|
4234
|
+
`);
|
|
4235
|
+
}
|
|
2974
4236
|
function registerIntegrityTools(server2) {
|
|
2975
4237
|
server2.tool(
|
|
2976
4238
|
"stackwright_pro_verify_otter_integrity",
|
|
@@ -2994,6 +4256,13 @@ function registerIntegrityTools(server2) {
|
|
|
2994
4256
|
}
|
|
2995
4257
|
const result = verifyAllOtters(resolved);
|
|
2996
4258
|
const allGood = result.failed.length === 0 && result.unknown.length === 0;
|
|
4259
|
+
if (!allGood) {
|
|
4260
|
+
emitIntegrityAuditEvent({
|
|
4261
|
+
otterDir: resolved,
|
|
4262
|
+
failed: result.failed,
|
|
4263
|
+
unknown: result.unknown
|
|
4264
|
+
});
|
|
4265
|
+
}
|
|
2997
4266
|
return {
|
|
2998
4267
|
content: [
|
|
2999
4268
|
{
|
|
@@ -3006,7 +4275,10 @@ function registerIntegrityTools(server2) {
|
|
|
3006
4275
|
unknownCount: result.unknown.length,
|
|
3007
4276
|
verified: result.verified,
|
|
3008
4277
|
failed: result.failed,
|
|
3009
|
-
unknown: result.unknown
|
|
4278
|
+
unknown: result.unknown,
|
|
4279
|
+
...allGood ? {} : {
|
|
4280
|
+
error: "INTEGRITY CHECK FAILED: SHA-256 mismatch detected in otter agent definitions. Do not proceed \u2014 otter files may have been tampered with."
|
|
4281
|
+
}
|
|
3010
4282
|
})
|
|
3011
4283
|
}
|
|
3012
4284
|
],
|
|
@@ -3017,14 +4289,14 @@ function registerIntegrityTools(server2) {
|
|
|
3017
4289
|
}
|
|
3018
4290
|
|
|
3019
4291
|
// src/tools/domain.ts
|
|
3020
|
-
import { z as
|
|
3021
|
-
import { readFileSync as
|
|
3022
|
-
import { join as
|
|
4292
|
+
import { z as z14 } from "zod";
|
|
4293
|
+
import { readFileSync as readFileSync7, existsSync as existsSync8 } from "fs";
|
|
4294
|
+
import { join as join8 } from "path";
|
|
3023
4295
|
function handleListCollections(input) {
|
|
3024
4296
|
const cwd = input._cwd ?? process.cwd();
|
|
3025
4297
|
const sources = [
|
|
3026
4298
|
{
|
|
3027
|
-
path:
|
|
4299
|
+
path: join8(cwd, ".stackwright", "artifacts", "data-config.json"),
|
|
3028
4300
|
source: "data-config.json",
|
|
3029
4301
|
parse: (raw) => {
|
|
3030
4302
|
const parsed = JSON.parse(raw);
|
|
@@ -3035,15 +4307,15 @@ function handleListCollections(input) {
|
|
|
3035
4307
|
}
|
|
3036
4308
|
},
|
|
3037
4309
|
{
|
|
3038
|
-
path:
|
|
4310
|
+
path: join8(cwd, "stackwright.yml"),
|
|
3039
4311
|
source: "stackwright.yml",
|
|
3040
4312
|
parse: extractCollectionsFromYaml
|
|
3041
4313
|
}
|
|
3042
4314
|
];
|
|
3043
4315
|
for (const { path: path3, source, parse } of sources) {
|
|
3044
|
-
if (!
|
|
4316
|
+
if (!existsSync8(path3)) continue;
|
|
3045
4317
|
try {
|
|
3046
|
-
const collections = parse(
|
|
4318
|
+
const collections = parse(readFileSync7(path3, "utf8"));
|
|
3047
4319
|
return {
|
|
3048
4320
|
text: JSON.stringify({ collections, source, collectionCount: collections.length }),
|
|
3049
4321
|
isError: false
|
|
@@ -3194,8 +4466,8 @@ function handleValidateWorkflow(input) {
|
|
|
3194
4466
|
if (input.workflow && Object.keys(input.workflow).length > 0) {
|
|
3195
4467
|
raw = input.workflow;
|
|
3196
4468
|
} else {
|
|
3197
|
-
const artifactPath =
|
|
3198
|
-
if (!
|
|
4469
|
+
const artifactPath = join8(cwd, ".stackwright", "artifacts", "workflow-config.json");
|
|
4470
|
+
if (!existsSync8(artifactPath)) {
|
|
3199
4471
|
return fail([
|
|
3200
4472
|
{
|
|
3201
4473
|
code: "NO_WORKFLOW",
|
|
@@ -3204,7 +4476,7 @@ function handleValidateWorkflow(input) {
|
|
|
3204
4476
|
]);
|
|
3205
4477
|
}
|
|
3206
4478
|
try {
|
|
3207
|
-
raw = JSON.parse(
|
|
4479
|
+
raw = JSON.parse(readFileSync7(artifactPath, "utf8"));
|
|
3208
4480
|
} catch (err) {
|
|
3209
4481
|
return fail([{ code: "INVALID_JSON", message: `Failed to parse workflow artifact: ${err}` }]);
|
|
3210
4482
|
}
|
|
@@ -3403,7 +4675,7 @@ function registerDomainTools(server2) {
|
|
|
3403
4675
|
"stackwright_pro_resolve_data_strategy",
|
|
3404
4676
|
"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
4677
|
{
|
|
3406
|
-
strategy:
|
|
4678
|
+
strategy: z14.string().describe(
|
|
3407
4679
|
'The data-1 answer value: "pulse-fast", "isr-fast", "isr-standard", or "isr-slow"'
|
|
3408
4680
|
)
|
|
3409
4681
|
},
|
|
@@ -3413,7 +4685,7 @@ function registerDomainTools(server2) {
|
|
|
3413
4685
|
"stackwright_pro_validate_workflow",
|
|
3414
4686
|
"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
4687
|
{
|
|
3416
|
-
workflow:
|
|
4688
|
+
workflow: jsonCoerce(z14.record(z14.string(), z14.unknown()).optional()).describe(
|
|
3417
4689
|
"Parsed workflow object. If omitted, reads from .stackwright/artifacts/workflow-config.json"
|
|
3418
4690
|
)
|
|
3419
4691
|
},
|
|
@@ -3421,18 +4693,109 @@ function registerDomainTools(server2) {
|
|
|
3421
4693
|
);
|
|
3422
4694
|
}
|
|
3423
4695
|
|
|
4696
|
+
// src/tools/type-schemas.ts
|
|
4697
|
+
import { z as z15 } from "zod";
|
|
4698
|
+
function buildTypeSchemaSummary() {
|
|
4699
|
+
return {
|
|
4700
|
+
version: "1.0",
|
|
4701
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4702
|
+
domains: {
|
|
4703
|
+
workflow: {
|
|
4704
|
+
description: "Workflow DSL \u2014 step definitions, auth blocks, field types, conditions",
|
|
4705
|
+
schemas: [
|
|
4706
|
+
"WorkflowFileSchema",
|
|
4707
|
+
"WorkflowDefinitionSchema",
|
|
4708
|
+
"WorkflowStepSchema",
|
|
4709
|
+
"WorkflowStepTypeSchema",
|
|
4710
|
+
"WorkflowFieldSchema",
|
|
4711
|
+
"WorkflowActionSchema",
|
|
4712
|
+
"WorkflowAuthSchema",
|
|
4713
|
+
"WorkflowThemeSchema",
|
|
4714
|
+
"TransitionConditionSchema",
|
|
4715
|
+
"PersistenceSchema"
|
|
4716
|
+
],
|
|
4717
|
+
otter: "stackwright-pro-workflow-otter",
|
|
4718
|
+
artifactKey: "workflowConfig"
|
|
4719
|
+
},
|
|
4720
|
+
auth: {
|
|
4721
|
+
description: "Authentication providers \u2014 PKI/CAC, OIDC, RBAC configuration",
|
|
4722
|
+
schemas: [
|
|
4723
|
+
"authConfigSchema",
|
|
4724
|
+
"pkiConfigSchema",
|
|
4725
|
+
"oidcConfigSchema",
|
|
4726
|
+
"rbacConfigSchema",
|
|
4727
|
+
"componentAuthSchema",
|
|
4728
|
+
"authUserSchema",
|
|
4729
|
+
"authSessionSchema"
|
|
4730
|
+
],
|
|
4731
|
+
otter: "stackwright-pro-auth-otter",
|
|
4732
|
+
artifactKey: "authConfig"
|
|
4733
|
+
},
|
|
4734
|
+
openapi: {
|
|
4735
|
+
description: "OpenAPI spec integration \u2014 collection config, endpoint filters, actions",
|
|
4736
|
+
interfaces: [
|
|
4737
|
+
"OpenAPIConfig",
|
|
4738
|
+
"ActionConfig",
|
|
4739
|
+
"EndpointFilter",
|
|
4740
|
+
"ApprovedSpec",
|
|
4741
|
+
"PrebuildSecurityConfig",
|
|
4742
|
+
"SiteConfig",
|
|
4743
|
+
"ValidationResult"
|
|
4744
|
+
],
|
|
4745
|
+
otter: "stackwright-pro-api-otter",
|
|
4746
|
+
artifactKey: "apiConfig"
|
|
4747
|
+
},
|
|
4748
|
+
pulse: {
|
|
4749
|
+
description: "Real-time data polling \u2014 source-agnostic polling options and states",
|
|
4750
|
+
interfaces: ["PulseOptions", "PulseMeta", "PulseState"],
|
|
4751
|
+
note: "React-bound types (PulseProps, PulseIndicatorProps) remain in @stackwright-pro/pulse"
|
|
4752
|
+
},
|
|
4753
|
+
enterprise: {
|
|
4754
|
+
description: "Enterprise collection access \u2014 multi-tenant provider extension",
|
|
4755
|
+
interfaces: [
|
|
4756
|
+
"EnterpriseCollectionProvider",
|
|
4757
|
+
"CollectionProvider",
|
|
4758
|
+
"CollectionEntry",
|
|
4759
|
+
"CollectionListOptions",
|
|
4760
|
+
"CollectionListResult",
|
|
4761
|
+
"TenantFilter",
|
|
4762
|
+
"Collection"
|
|
4763
|
+
],
|
|
4764
|
+
note: "CollectionProvider, CollectionEntry, CollectionListOptions, and CollectionListResult are re-exported from @stackwright/types (^1.5.0). EnterpriseCollectionProvider, TenantFilter, and Collection are Pro-only extensions."
|
|
4765
|
+
}
|
|
4766
|
+
}
|
|
4767
|
+
};
|
|
4768
|
+
}
|
|
4769
|
+
function registerTypeSchemasTool(server2) {
|
|
4770
|
+
server2.tool(
|
|
4771
|
+
"stackwright_pro_get_type_schemas",
|
|
4772
|
+
"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.",
|
|
4773
|
+
{
|
|
4774
|
+
format: z15.enum(["full", "domains-only"]).optional().default("full").describe("full = complete summary with all fields; domains-only = just domain names")
|
|
4775
|
+
},
|
|
4776
|
+
async ({ format }) => {
|
|
4777
|
+
const summary = buildTypeSchemaSummary();
|
|
4778
|
+
const output = format === "domains-only" ? Object.keys(summary.domains) : summary;
|
|
4779
|
+
return {
|
|
4780
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
4781
|
+
};
|
|
4782
|
+
}
|
|
4783
|
+
);
|
|
4784
|
+
}
|
|
4785
|
+
|
|
3424
4786
|
// package.json
|
|
3425
4787
|
var package_default = {
|
|
3426
4788
|
dependencies: {
|
|
4789
|
+
"@stackwright-pro/types": "workspace:*",
|
|
3427
4790
|
"@modelcontextprotocol/sdk": "^1.10.0",
|
|
3428
4791
|
"@stackwright-pro/cli-data-explorer": "workspace:*",
|
|
3429
|
-
zod: "^4.3
|
|
4792
|
+
zod: "^4.4.3"
|
|
3430
4793
|
},
|
|
3431
4794
|
devDependencies: {
|
|
3432
|
-
"@types/node": "
|
|
3433
|
-
tsup: "
|
|
3434
|
-
typescript: "
|
|
3435
|
-
vitest: "
|
|
4795
|
+
"@types/node": "catalog:",
|
|
4796
|
+
tsup: "catalog:",
|
|
4797
|
+
typescript: "catalog:",
|
|
4798
|
+
vitest: "catalog:"
|
|
3436
4799
|
},
|
|
3437
4800
|
scripts: {
|
|
3438
4801
|
prepublishOnly: "node scripts/verify-integrity-sync.js",
|
|
@@ -3443,10 +4806,13 @@ var package_default = {
|
|
|
3443
4806
|
"test:coverage": "vitest run --coverage"
|
|
3444
4807
|
},
|
|
3445
4808
|
name: "@stackwright-pro/mcp",
|
|
3446
|
-
version: "0.2.0-alpha.
|
|
4809
|
+
version: "0.2.0-alpha.61",
|
|
3447
4810
|
description: "MCP tools for Stackwright Pro - Data Explorer, Security, ISR, and Dashboard generation",
|
|
3448
|
-
license: "
|
|
4811
|
+
license: "SEE LICENSE IN LICENSE",
|
|
3449
4812
|
main: "./dist/server.js",
|
|
4813
|
+
bin: {
|
|
4814
|
+
"stackwright-pro-mcp": "./dist/server.js"
|
|
4815
|
+
},
|
|
3450
4816
|
module: "./dist/server.mjs",
|
|
3451
4817
|
types: "./dist/server.d.ts",
|
|
3452
4818
|
exports: {
|
|
@@ -3459,6 +4825,11 @@ var package_default = {
|
|
|
3459
4825
|
types: "./dist/integrity.d.ts",
|
|
3460
4826
|
import: "./dist/integrity.mjs",
|
|
3461
4827
|
require: "./dist/integrity.js"
|
|
4828
|
+
},
|
|
4829
|
+
"./type-schemas": {
|
|
4830
|
+
types: "./dist/tools/type-schemas.d.ts",
|
|
4831
|
+
import: "./dist/tools/type-schemas.mjs",
|
|
4832
|
+
require: "./dist/tools/type-schemas.js"
|
|
3462
4833
|
}
|
|
3463
4834
|
},
|
|
3464
4835
|
files: [
|
|
@@ -3486,9 +4857,20 @@ registerPipelineTools(server);
|
|
|
3486
4857
|
registerSafeWriteTools(server);
|
|
3487
4858
|
registerAuthTools(server);
|
|
3488
4859
|
registerIntegrityTools(server);
|
|
4860
|
+
registerArtifactSigningTools(server);
|
|
3489
4861
|
registerDomainTools(server);
|
|
4862
|
+
registerTypeSchemasTool(server);
|
|
3490
4863
|
async function main() {
|
|
3491
4864
|
const transport = new StdioServerTransport();
|
|
4865
|
+
try {
|
|
4866
|
+
const servicesRegisterPkg = "@stackwright-services/mcp/register";
|
|
4867
|
+
const mod = await import(servicesRegisterPkg);
|
|
4868
|
+
if (typeof mod.registerServicesTools === "function") {
|
|
4869
|
+
mod.registerServicesTools(server);
|
|
4870
|
+
console.error("Stackwright Services tools registered on pro MCP");
|
|
4871
|
+
}
|
|
4872
|
+
} catch {
|
|
4873
|
+
}
|
|
3492
4874
|
await server.connect(transport);
|
|
3493
4875
|
console.error("Stackwright Pro MCP server running on stdio");
|
|
3494
4876
|
}
|