@stackwright-pro/mcp 0.2.0-alpha.3 → 0.2.0-alpha.32
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/README.md +236 -0
- package/dist/integrity.d.mts +32 -0
- package/dist/integrity.d.ts +32 -0
- package/dist/integrity.js +252 -0
- package/dist/integrity.js.map +1 -0
- package/dist/integrity.mjs +224 -0
- package/dist/integrity.mjs.map +1 -0
- package/dist/server.js +3109 -355
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +3096 -342
- 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 +18 -4
package/dist/server.js
CHANGED
|
@@ -27,15 +27,44 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
|
27
27
|
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
28
28
|
|
|
29
29
|
// src/tools/data-explorer.ts
|
|
30
|
-
var
|
|
30
|
+
var import_zod2 = require("zod");
|
|
31
31
|
var import_cli_data_explorer = require("@stackwright-pro/cli-data-explorer");
|
|
32
|
+
|
|
33
|
+
// src/coerce.ts
|
|
34
|
+
var import_zod = require("zod");
|
|
35
|
+
function jsonCoerce(schema) {
|
|
36
|
+
return import_zod.z.preprocess((v) => {
|
|
37
|
+
if (typeof v === "string") {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(v);
|
|
40
|
+
} catch {
|
|
41
|
+
return v;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return v;
|
|
45
|
+
}, schema);
|
|
46
|
+
}
|
|
47
|
+
function boolCoerce(schema) {
|
|
48
|
+
return import_zod.z.preprocess((v) => v === "true" ? true : v === "false" ? false : v, schema);
|
|
49
|
+
}
|
|
50
|
+
function numCoerce(schema) {
|
|
51
|
+
return import_zod.z.preprocess((v) => {
|
|
52
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
53
|
+
const n = Number(v);
|
|
54
|
+
if (!Number.isNaN(n)) return n;
|
|
55
|
+
}
|
|
56
|
+
return v;
|
|
57
|
+
}, schema);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/tools/data-explorer.ts
|
|
32
61
|
function registerDataExplorerTools(server2) {
|
|
33
62
|
server2.tool(
|
|
34
63
|
"stackwright_pro_list_entities",
|
|
35
64
|
"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.",
|
|
36
65
|
{
|
|
37
|
-
specPath:
|
|
38
|
-
projectRoot:
|
|
66
|
+
specPath: import_zod2.z.string().optional().describe("Path to OpenAPI spec file (YAML or JSON)"),
|
|
67
|
+
projectRoot: import_zod2.z.string().optional().describe("Project root directory (auto-detected if omitted)")
|
|
39
68
|
},
|
|
40
69
|
async ({ specPath, projectRoot }) => {
|
|
41
70
|
const result = (0, import_cli_data_explorer.listEntities)({
|
|
@@ -83,9 +112,13 @@ function registerDataExplorerTools(server2) {
|
|
|
83
112
|
"stackwright_pro_generate_filter",
|
|
84
113
|
"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.",
|
|
85
114
|
{
|
|
86
|
-
selectedEntities:
|
|
87
|
-
|
|
88
|
-
|
|
115
|
+
selectedEntities: jsonCoerce(import_zod2.z.array(import_zod2.z.string())).describe(
|
|
116
|
+
'Entity slugs to include (e.g., ["equipment", "supplies"])'
|
|
117
|
+
),
|
|
118
|
+
excludePatterns: jsonCoerce(import_zod2.z.array(import_zod2.z.string()).optional()).describe(
|
|
119
|
+
'Glob patterns to exclude (e.g., ["/admin/**", "/reports/**"])'
|
|
120
|
+
),
|
|
121
|
+
projectRoot: import_zod2.z.string().optional().describe("Project root directory")
|
|
89
122
|
},
|
|
90
123
|
async ({ selectedEntities, excludePatterns, projectRoot }) => {
|
|
91
124
|
const result = (0, import_cli_data_explorer.generateFilter)({
|
|
@@ -141,7 +174,7 @@ function registerDataExplorerTools(server2) {
|
|
|
141
174
|
}
|
|
142
175
|
|
|
143
176
|
// src/tools/security.ts
|
|
144
|
-
var
|
|
177
|
+
var import_zod3 = require("zod");
|
|
145
178
|
var import_crypto = require("crypto");
|
|
146
179
|
var import_fs = __toESM(require("fs"));
|
|
147
180
|
var import_path = __toESM(require("path"));
|
|
@@ -150,8 +183,8 @@ function registerSecurityTools(server2) {
|
|
|
150
183
|
"stackwright_pro_validate_spec",
|
|
151
184
|
"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.",
|
|
152
185
|
{
|
|
153
|
-
specPath:
|
|
154
|
-
configPath:
|
|
186
|
+
specPath: import_zod3.z.string().describe("URL or file path to the OpenAPI spec to validate"),
|
|
187
|
+
configPath: import_zod3.z.string().optional().describe("Path to stackwright.yml with prebuild.security config")
|
|
155
188
|
},
|
|
156
189
|
async ({ specPath, configPath }) => {
|
|
157
190
|
let securityEnabled = false;
|
|
@@ -259,16 +292,15 @@ Status: Valid (${allowlist.length} specs on allowlist)`
|
|
|
259
292
|
"stackwright_pro_add_approved_spec",
|
|
260
293
|
"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.",
|
|
261
294
|
{
|
|
262
|
-
name:
|
|
263
|
-
url:
|
|
264
|
-
configPath:
|
|
295
|
+
name: import_zod3.z.string().describe("Human-readable name for the spec"),
|
|
296
|
+
url: import_zod3.z.string().describe("URL or file path to the OpenAPI spec"),
|
|
297
|
+
configPath: import_zod3.z.string().optional().describe("Path to stackwright.yml")
|
|
265
298
|
},
|
|
266
299
|
async ({ name, url, configPath }) => {
|
|
267
300
|
const configFile = configPath || import_path.default.join(process.cwd(), "stackwright.yml");
|
|
268
301
|
let sha256 = "<computed-at-build>";
|
|
269
302
|
try {
|
|
270
303
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
271
|
-
sha256 = "<computed-at-build>";
|
|
272
304
|
} else if (import_fs.default.existsSync(url)) {
|
|
273
305
|
const specContent = import_fs.default.readFileSync(url, "utf8");
|
|
274
306
|
sha256 = (0, import_crypto.createHash)("sha256").update(specContent).digest("hex");
|
|
@@ -315,7 +347,7 @@ SHA-256 computed: ${sha256}`
|
|
|
315
347
|
"stackwright_pro_list_approved_specs",
|
|
316
348
|
"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.",
|
|
317
349
|
{
|
|
318
|
-
configPath:
|
|
350
|
+
configPath: import_zod3.z.string().optional().describe("Path to stackwright.yml")
|
|
319
351
|
},
|
|
320
352
|
async ({ configPath }) => {
|
|
321
353
|
const configFile = configPath || import_path.default.join(process.cwd(), "stackwright.yml");
|
|
@@ -384,16 +416,18 @@ SHA-256 computed: ${sha256}`
|
|
|
384
416
|
}
|
|
385
417
|
|
|
386
418
|
// src/tools/isr.ts
|
|
387
|
-
var
|
|
419
|
+
var import_zod4 = require("zod");
|
|
388
420
|
function registerIsrTools(server2) {
|
|
389
421
|
server2.tool(
|
|
390
422
|
"stackwright_pro_configure_isr",
|
|
391
423
|
"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.",
|
|
392
424
|
{
|
|
393
|
-
collection:
|
|
394
|
-
revalidateSeconds:
|
|
395
|
-
|
|
396
|
-
|
|
425
|
+
collection: import_zod4.z.string().describe('Collection name (e.g., "equipment", "supplies")'),
|
|
426
|
+
revalidateSeconds: numCoerce(import_zod4.z.number().optional()).describe(
|
|
427
|
+
"Revalidation interval in seconds (default: 60)"
|
|
428
|
+
),
|
|
429
|
+
fallback: import_zod4.z.enum(["blocking", "true", "false"]).optional().describe("Fallback behavior for new pages"),
|
|
430
|
+
configPath: import_zod4.z.string().optional().describe("Path to stackwright.yml")
|
|
397
431
|
},
|
|
398
432
|
async ({ collection, revalidateSeconds = 60, fallback = "blocking", configPath }) => {
|
|
399
433
|
const revalidate = revalidateSeconds;
|
|
@@ -404,7 +438,7 @@ function registerIsrTools(server2) {
|
|
|
404
438
|
isr:
|
|
405
439
|
revalidate: ${revalidate}
|
|
406
440
|
fallback: ${fallback}`;
|
|
407
|
-
let description
|
|
441
|
+
let description;
|
|
408
442
|
if (revalidate < 60) {
|
|
409
443
|
description = "\u26A1 Very fresh data (revalidate every " + revalidate + "s)";
|
|
410
444
|
} else if (revalidate < 300) {
|
|
@@ -436,14 +470,16 @@ ${yamlSnippet}
|
|
|
436
470
|
"stackwright_pro_configure_isr_batch",
|
|
437
471
|
"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.",
|
|
438
472
|
{
|
|
439
|
-
collections:
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
473
|
+
collections: jsonCoerce(
|
|
474
|
+
import_zod4.z.array(
|
|
475
|
+
import_zod4.z.object({
|
|
476
|
+
name: import_zod4.z.string().describe("Collection name"),
|
|
477
|
+
revalidateSeconds: numCoerce(import_zod4.z.number().optional()).describe("Revalidation interval")
|
|
478
|
+
})
|
|
479
|
+
)
|
|
444
480
|
).describe("Array of collection configurations"),
|
|
445
|
-
defaultFallback:
|
|
446
|
-
configPath:
|
|
481
|
+
defaultFallback: import_zod4.z.enum(["blocking", "true", "false"]).optional().describe("Default fallback behavior"),
|
|
482
|
+
configPath: import_zod4.z.string().optional().describe("Path to stackwright.yml")
|
|
447
483
|
},
|
|
448
484
|
async ({ collections, defaultFallback = "blocking", configPath }) => {
|
|
449
485
|
const lines = [`\u2699\uFE0F Batch ISR Configuration:
|
|
@@ -471,17 +507,17 @@ Fallback: ${defaultFallback}`);
|
|
|
471
507
|
}
|
|
472
508
|
|
|
473
509
|
// src/tools/dashboard.ts
|
|
474
|
-
var
|
|
510
|
+
var import_zod5 = require("zod");
|
|
475
511
|
var import_cli_data_explorer2 = require("@stackwright-pro/cli-data-explorer");
|
|
476
512
|
function registerDashboardTools(server2) {
|
|
477
513
|
server2.tool(
|
|
478
514
|
"stackwright_pro_generate_dashboard",
|
|
479
515
|
"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.",
|
|
480
516
|
{
|
|
481
|
-
entities:
|
|
482
|
-
layout:
|
|
483
|
-
pageTitle:
|
|
484
|
-
specPath:
|
|
517
|
+
entities: jsonCoerce(import_zod5.z.array(import_zod5.z.string())).describe("Entity slugs to include in dashboard"),
|
|
518
|
+
layout: import_zod5.z.enum(["grid", "table", "mixed"]).optional().describe("Dashboard layout style"),
|
|
519
|
+
pageTitle: import_zod5.z.string().optional().describe("Page title"),
|
|
520
|
+
specPath: import_zod5.z.string().optional().describe("Path to OpenAPI spec for entity details")
|
|
485
521
|
},
|
|
486
522
|
async ({ entities, layout = "mixed", pageTitle, specPath }) => {
|
|
487
523
|
let entityDetails = [];
|
|
@@ -567,9 +603,9 @@ ${yaml}
|
|
|
567
603
|
"stackwright_pro_generate_detail_page",
|
|
568
604
|
"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.",
|
|
569
605
|
{
|
|
570
|
-
entity:
|
|
571
|
-
slugField:
|
|
572
|
-
specPath:
|
|
606
|
+
entity: import_zod5.z.string().describe('Entity slug (e.g., "equipment")'),
|
|
607
|
+
slugField: import_zod5.z.string().optional().describe('Field to use as URL slug (default: "id")'),
|
|
608
|
+
specPath: import_zod5.z.string().optional().describe("Path to OpenAPI spec for field details")
|
|
573
609
|
},
|
|
574
610
|
async ({ entity, slugField = "id", specPath }) => {
|
|
575
611
|
let fields = [];
|
|
@@ -645,298 +681,170 @@ ${yaml}
|
|
|
645
681
|
}
|
|
646
682
|
|
|
647
683
|
// src/tools/clarification.ts
|
|
648
|
-
var
|
|
649
|
-
var
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
if (!startupOutput.includes("Starting")) {
|
|
684
|
-
console.error("[Python Clarification]", data.toString().trim());
|
|
685
|
-
}
|
|
686
|
-
});
|
|
687
|
-
proc.on("error", (err) => {
|
|
688
|
-
if (!started) {
|
|
689
|
-
activeServers.delete(sessionId);
|
|
690
|
-
reject(new Error(`Failed to start Python server: ${err.message}`));
|
|
691
|
-
}
|
|
692
|
-
});
|
|
693
|
-
proc.on("exit", (code) => {
|
|
694
|
-
activeServers.delete(sessionId);
|
|
695
|
-
if ((0, import_fs2.existsSync)(socketPath)) {
|
|
696
|
-
try {
|
|
697
|
-
(0, import_fs2.unlinkSync)(socketPath);
|
|
698
|
-
} catch {
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
});
|
|
702
|
-
setTimeout(() => {
|
|
703
|
-
if (!started) {
|
|
704
|
-
proc.kill();
|
|
705
|
-
activeServers.delete(sessionId);
|
|
706
|
-
reject(new Error("Python server startup timeout"));
|
|
707
|
-
}
|
|
708
|
-
}, 1e4);
|
|
709
|
-
});
|
|
710
|
-
}
|
|
711
|
-
async function stopPythonServer(sessionId) {
|
|
712
|
-
const proc = activeServers.get(sessionId);
|
|
713
|
-
if (proc) {
|
|
714
|
-
proc.kill("SIGTERM");
|
|
715
|
-
activeServers.delete(sessionId);
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
async function httpRequest(host, port, path3, body) {
|
|
719
|
-
const http = await import("http");
|
|
720
|
-
return new Promise((resolve, reject) => {
|
|
721
|
-
const url = new URL(path3, `http://${host}:${port}`);
|
|
722
|
-
const reqOptions = {
|
|
723
|
-
hostname: host,
|
|
724
|
-
port,
|
|
725
|
-
path: url.pathname,
|
|
726
|
-
method: body ? "POST" : "GET",
|
|
727
|
-
headers: {
|
|
728
|
-
"Content-Type": "application/json"
|
|
729
|
-
}
|
|
684
|
+
var import_zod6 = require("zod");
|
|
685
|
+
var CONTRADICTION_PATTERNS = [
|
|
686
|
+
{
|
|
687
|
+
keywords: ["minimal", "clean", "simple"],
|
|
688
|
+
conflicts: ["vibrant", "rich", "content-heavy", "playful"]
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
keywords: ["dark", "dark mode"],
|
|
692
|
+
conflicts: ["light mode only", "light"]
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
keywords: ["enterprise", "professional", "corporate"],
|
|
696
|
+
conflicts: ["playful", "casual", "fun"]
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
keywords: ["accessible", "wcag", "section 508"],
|
|
700
|
+
conflicts: ["compact", "dense", "small text"]
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
keywords: ["government", "defense", "federal"],
|
|
704
|
+
conflicts: ["public", "no auth", "consumer"]
|
|
705
|
+
}
|
|
706
|
+
];
|
|
707
|
+
function handleClarify(input) {
|
|
708
|
+
const { question_type, question, choices, priority, target_field, context } = input;
|
|
709
|
+
const base = {
|
|
710
|
+
action: "ask_user",
|
|
711
|
+
targetField: target_field
|
|
712
|
+
};
|
|
713
|
+
if (question_type === "closed_choice" && choices && choices.length > 0) {
|
|
714
|
+
const header = truncateHeader(question);
|
|
715
|
+
base.adaptedQuestion = {
|
|
716
|
+
question,
|
|
717
|
+
header,
|
|
718
|
+
options: choices.map((c) => ({ label: c }))
|
|
730
719
|
};
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
} else {
|
|
742
|
-
resolve(parsed);
|
|
743
|
-
}
|
|
744
|
-
} catch (e) {
|
|
745
|
-
reject(new Error(`Failed to parse response: ${data}`));
|
|
746
|
-
}
|
|
747
|
-
});
|
|
748
|
-
});
|
|
749
|
-
req.on("error", reject);
|
|
750
|
-
if (body) {
|
|
751
|
-
req.write(JSON.stringify(body));
|
|
752
|
-
}
|
|
753
|
-
req.end();
|
|
754
|
-
});
|
|
720
|
+
} else {
|
|
721
|
+
const contextPrefix = context ? `Context: ${context}
|
|
722
|
+
|
|
723
|
+
` : "";
|
|
724
|
+
base.prompt = `${contextPrefix}${question}`;
|
|
725
|
+
}
|
|
726
|
+
if (priority === "optional") {
|
|
727
|
+
base.suggestedDefault = inferDefault(question_type, choices);
|
|
728
|
+
}
|
|
729
|
+
return base;
|
|
755
730
|
}
|
|
756
|
-
function
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
731
|
+
function handleDetectConflict(input) {
|
|
732
|
+
const preferenceLower = input.stated_preference.toLowerCase();
|
|
733
|
+
const valuesLower = Object.values(input.selected_values).map((v) => v.toLowerCase());
|
|
734
|
+
for (const pattern of CONTRADICTION_PATTERNS) {
|
|
735
|
+
const preferenceMatch = pattern.keywords.some((kw) => preferenceLower.includes(kw));
|
|
736
|
+
if (!preferenceMatch) continue;
|
|
737
|
+
const conflictingValues = valuesLower.filter(
|
|
738
|
+
(val) => pattern.conflicts.some((c) => val.includes(c))
|
|
739
|
+
);
|
|
740
|
+
if (conflictingValues.length > 0) {
|
|
741
|
+
const matchedKeywords = pattern.keywords.filter((kw) => preferenceLower.includes(kw));
|
|
742
|
+
const primaryKeyword = matchedKeywords[0] ?? "preference";
|
|
743
|
+
return {
|
|
744
|
+
conflict: true,
|
|
745
|
+
description: `Stated preference includes "${matchedKeywords.join(", ")}" but selections contain "${conflictingValues.join(", ")}" \u2014 these are contradictory.`,
|
|
746
|
+
resolution_options: [
|
|
747
|
+
`Align selections with the "${primaryKeyword}" preference`,
|
|
748
|
+
`Revise stated preference to match current selections`,
|
|
749
|
+
"Ask user which direction they actually want"
|
|
750
|
+
]
|
|
751
|
+
};
|
|
765
752
|
}
|
|
766
753
|
}
|
|
767
|
-
return
|
|
754
|
+
return { conflict: false };
|
|
755
|
+
}
|
|
756
|
+
function truncateHeader(text) {
|
|
757
|
+
const MAX = 50;
|
|
758
|
+
if (text.length <= MAX) return text;
|
|
759
|
+
return text.slice(0, MAX - 1) + "\u2026";
|
|
760
|
+
}
|
|
761
|
+
function inferDefault(questionType, choices) {
|
|
762
|
+
if (questionType === "closed_choice" && choices && choices.length > 0) {
|
|
763
|
+
return choices[0] ?? "(first choice)";
|
|
764
|
+
}
|
|
765
|
+
return "(use sensible project default)";
|
|
768
766
|
}
|
|
769
767
|
function registerClarificationTools(server2) {
|
|
770
768
|
server2.tool(
|
|
771
769
|
"stackwright_pro_clarify",
|
|
772
|
-
"Ask the user for clarification when
|
|
770
|
+
"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).",
|
|
773
771
|
{
|
|
774
|
-
context:
|
|
775
|
-
question_type:
|
|
776
|
-
question:
|
|
777
|
-
choices:
|
|
778
|
-
|
|
779
|
-
|
|
772
|
+
context: import_zod6.z.string().optional().describe("Context about what the otter is trying to do"),
|
|
773
|
+
question_type: import_zod6.z.enum(["closed_choice", "open_text"]).describe("Type of question being asked"),
|
|
774
|
+
question: import_zod6.z.string().describe("The clarification question to ask the user"),
|
|
775
|
+
choices: jsonCoerce(import_zod6.z.array(import_zod6.z.string()).optional()).describe(
|
|
776
|
+
"Options for closed_choice questions"
|
|
777
|
+
),
|
|
778
|
+
priority: import_zod6.z.enum(["blocking", "preferred", "optional"]).optional().default("preferred").describe("How critical is this clarification? Default: preferred"),
|
|
779
|
+
target_field: import_zod6.z.string().optional().describe("What field/config does this clarify?")
|
|
780
780
|
},
|
|
781
|
-
async ({ context, question_type, question, choices, priority
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
const response = await httpRequest(
|
|
803
|
-
port === 8765 ? "127.0.0.1" : "127.0.0.1",
|
|
804
|
-
port,
|
|
805
|
-
"/clarify",
|
|
806
|
-
{ request }
|
|
807
|
-
);
|
|
808
|
-
await stopPythonServer(sessionId);
|
|
809
|
-
const decision = response.decision;
|
|
810
|
-
const value = decision.value;
|
|
811
|
-
const source = decision.source;
|
|
812
|
-
const explicit = decision.explicit ? "explicitly" : "via fallback";
|
|
813
|
-
if (response.fallback_used) {
|
|
814
|
-
return {
|
|
815
|
-
content: [
|
|
816
|
-
{
|
|
817
|
-
type: "text",
|
|
818
|
-
text: `\u26A0\uFE0F Clarification fallback used: ${response.fallback_reason || "No user input available"}
|
|
819
|
-
|
|
820
|
-
Default value used: ${JSON.stringify(value)}
|
|
821
|
-
|
|
822
|
-
\u{1F4A1} Consider following up with the user later if this default isn't appropriate.`
|
|
823
|
-
}
|
|
824
|
-
]
|
|
825
|
-
};
|
|
826
|
-
}
|
|
827
|
-
return {
|
|
828
|
-
content: [
|
|
829
|
-
{
|
|
830
|
-
type: "text",
|
|
831
|
-
text: `\u2705 User clarified (${source}): ${JSON.stringify(value)}
|
|
832
|
-
|
|
833
|
-
Use this value to continue execution.`
|
|
834
|
-
}
|
|
835
|
-
]
|
|
836
|
-
};
|
|
837
|
-
} catch (error) {
|
|
838
|
-
await stopPythonServer(sessionId);
|
|
839
|
-
return {
|
|
840
|
-
content: [
|
|
841
|
-
{
|
|
842
|
-
type: "text",
|
|
843
|
-
text: `\u274C Clarification failed: ${error instanceof Error ? error.message : "Unknown error"}
|
|
844
|
-
|
|
845
|
-
Cannot proceed without user input. Consider:
|
|
846
|
-
1. Using a reasonable default
|
|
847
|
-
2. Asking the user directly in your response
|
|
848
|
-
3. Skipping this step if optional`
|
|
849
|
-
}
|
|
850
|
-
],
|
|
851
|
-
isError: true
|
|
852
|
-
};
|
|
853
|
-
}
|
|
781
|
+
async ({ context, question_type, question, choices, priority, target_field }) => {
|
|
782
|
+
const result = handleClarify({
|
|
783
|
+
context: context ?? void 0,
|
|
784
|
+
question_type,
|
|
785
|
+
question,
|
|
786
|
+
choices: choices ?? void 0,
|
|
787
|
+
priority: priority ?? "preferred",
|
|
788
|
+
target_field: target_field ?? void 0
|
|
789
|
+
});
|
|
790
|
+
return {
|
|
791
|
+
content: [
|
|
792
|
+
{
|
|
793
|
+
type: "text",
|
|
794
|
+
text: `\u{1F9A6} Clarification needed${target_field ? ` for "${target_field}"` : ""}. Present the following to the user. ` + (result.adaptedQuestion ? "Pass the adaptedQuestion to ask_user_question directly." : "Ask the user the prompt below.")
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
type: "text",
|
|
798
|
+
text: JSON.stringify(result)
|
|
799
|
+
}
|
|
800
|
+
]
|
|
801
|
+
};
|
|
854
802
|
}
|
|
855
803
|
);
|
|
856
804
|
server2.tool(
|
|
857
805
|
"stackwright_pro_detect_conflict",
|
|
858
|
-
"Detect when a user's stated preference conflicts with their selected choices.
|
|
806
|
+
"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.",
|
|
859
807
|
{
|
|
860
|
-
stated_preference:
|
|
861
|
-
selected_values:
|
|
808
|
+
stated_preference: import_zod6.z.string().describe("What the user said they wanted"),
|
|
809
|
+
selected_values: jsonCoerce(import_zod6.z.record(import_zod6.z.string(), import_zod6.z.string())).describe(
|
|
810
|
+
"What the user actually selected (field \u2192 value)"
|
|
811
|
+
)
|
|
862
812
|
},
|
|
863
813
|
async ({ stated_preference, selected_values }) => {
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
{ stated_preference, selected_values }
|
|
872
|
-
);
|
|
873
|
-
await stopPythonServer(sessionId);
|
|
874
|
-
if (response.conflict) {
|
|
875
|
-
const data = response.data;
|
|
876
|
-
return {
|
|
877
|
-
content: [
|
|
878
|
-
{
|
|
879
|
-
type: "text",
|
|
880
|
-
text: `\u26A0\uFE0F CONFLICT DETECTED
|
|
814
|
+
const result = handleDetectConflict({ stated_preference, selected_values });
|
|
815
|
+
if (result.conflict) {
|
|
816
|
+
return {
|
|
817
|
+
content: [
|
|
818
|
+
{
|
|
819
|
+
type: "text",
|
|
820
|
+
text: `\u26A0\uFE0F CONFLICT DETECTED
|
|
881
821
|
|
|
882
822
|
User stated: "${stated_preference}"
|
|
883
823
|
But selected: ${JSON.stringify(selected_values)}
|
|
884
824
|
|
|
885
|
-
|
|
825
|
+
${result.description}
|
|
886
826
|
|
|
887
|
-
Resolution options:
|
|
827
|
+
Resolution options:
|
|
828
|
+
${result.resolution_options?.map((o) => ` \u2022 ${o}`).join("\n")}
|
|
888
829
|
|
|
889
830
|
\u{1F4A1} Consider asking the user to reconcile this conflict.`
|
|
890
|
-
|
|
891
|
-
]
|
|
892
|
-
};
|
|
893
|
-
}
|
|
894
|
-
return {
|
|
895
|
-
content: [
|
|
831
|
+
},
|
|
896
832
|
{
|
|
897
833
|
type: "text",
|
|
898
|
-
text:
|
|
834
|
+
text: JSON.stringify(result)
|
|
899
835
|
}
|
|
900
836
|
]
|
|
901
837
|
};
|
|
902
|
-
} catch (error) {
|
|
903
|
-
await stopPythonServer(sessionId);
|
|
904
|
-
return {
|
|
905
|
-
content: [
|
|
906
|
-
{
|
|
907
|
-
type: "text",
|
|
908
|
-
text: `\u274C Conflict detection failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
909
|
-
}
|
|
910
|
-
],
|
|
911
|
-
isError: true
|
|
912
|
-
};
|
|
913
838
|
}
|
|
914
|
-
}
|
|
915
|
-
);
|
|
916
|
-
server2.tool(
|
|
917
|
-
"stackwright_pro_get_defaults",
|
|
918
|
-
"Get the current clarification defaults from config. Use this to understand what fallback values will be used if user doesn't provide input.",
|
|
919
|
-
{
|
|
920
|
-
config_path: import_zod5.z.string().optional().describe("Path to config file. Default: .stackwright/clarification.yaml")
|
|
921
|
-
},
|
|
922
|
-
async ({ config_path }) => {
|
|
923
839
|
return {
|
|
924
840
|
content: [
|
|
925
841
|
{
|
|
926
842
|
type: "text",
|
|
927
|
-
text:
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
- CLI args: --clarify-* flags
|
|
933
|
-
|
|
934
|
-
Default behaviors:
|
|
935
|
-
- allow_dont_know: true (users can skip)
|
|
936
|
-
- default_timeout: 120 seconds
|
|
937
|
-
- channel_priority: [tui, cli_args, config, defaults]
|
|
938
|
-
|
|
939
|
-
\u{1F4A1} Set CLARIFICATION_DEFAULT_<FIELD>=value to change defaults.`
|
|
843
|
+
text: "\u2705 No conflict detected between stated preference and selections."
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
type: "text",
|
|
847
|
+
text: JSON.stringify(result)
|
|
940
848
|
}
|
|
941
849
|
]
|
|
942
850
|
};
|
|
@@ -945,31 +853,53 @@ Default behaviors:
|
|
|
945
853
|
}
|
|
946
854
|
|
|
947
855
|
// src/tools/packages.ts
|
|
948
|
-
var
|
|
949
|
-
var
|
|
950
|
-
var
|
|
951
|
-
var
|
|
856
|
+
var import_zod7 = require("zod");
|
|
857
|
+
var import_fs2 = require("fs");
|
|
858
|
+
var import_child_process = require("child_process");
|
|
859
|
+
var import_path2 = __toESM(require("path"));
|
|
860
|
+
var BASELINE_DEPS = {
|
|
861
|
+
"@stackwright-pro/mcp": "latest",
|
|
862
|
+
"@stackwright-pro/otters": "latest",
|
|
863
|
+
"@stackwright-pro/openapi": "latest",
|
|
864
|
+
"@stackwright-pro/auth": "latest",
|
|
865
|
+
"@stackwright-pro/auth-nextjs": "latest",
|
|
866
|
+
zod: "^3.23.0"
|
|
867
|
+
};
|
|
868
|
+
var BASELINE_DEV_DEPS = {
|
|
869
|
+
"@stoplight/prism-cli": "^5.14.2"
|
|
870
|
+
};
|
|
952
871
|
function registerPackageTools(server2) {
|
|
953
872
|
server2.tool(
|
|
954
873
|
"stackwright_pro_setup_packages",
|
|
955
|
-
"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.",
|
|
874
|
+
"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.",
|
|
956
875
|
{
|
|
957
876
|
// FIX 3 (B-new-1): Zod v4 requires two-arg z.record(keySchema, valueSchema)
|
|
958
|
-
packages:
|
|
959
|
-
'Dependencies to add. Record<packageName, version>. e.g. { "@stackwright-pro/auth": "latest" }'
|
|
877
|
+
packages: jsonCoerce(import_zod7.z.record(import_zod7.z.string(), import_zod7.z.string()).optional().default({})).describe(
|
|
878
|
+
'Dependencies to add. Record<packageName, version>. e.g. { "@stackwright-pro/auth": "latest" }. Omit or pass {} when using includeBaseline: true.'
|
|
879
|
+
),
|
|
880
|
+
devPackages: jsonCoerce(import_zod7.z.record(import_zod7.z.string(), import_zod7.z.string()).optional()).describe(
|
|
881
|
+
"devDependencies to add. Same format as packages."
|
|
960
882
|
),
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
883
|
+
scripts: jsonCoerce(import_zod7.z.record(import_zod7.z.string(), import_zod7.z.string()).optional()).describe(
|
|
884
|
+
"npm scripts to add. Only adds if key does not already exist."
|
|
885
|
+
),
|
|
886
|
+
targetDir: import_zod7.z.string().optional().describe(
|
|
964
887
|
"Project directory containing package.json. Defaults to process.cwd(). Must be an absolute path within the current working directory."
|
|
965
888
|
),
|
|
966
|
-
runInstall:
|
|
889
|
+
runInstall: boolCoerce(import_zod7.z.boolean().optional().default(true)).describe(
|
|
890
|
+
"Run pnpm install after writing package.json. Defaults to true. Pass boolean true/false."
|
|
891
|
+
),
|
|
892
|
+
includeBaseline: boolCoerce(import_zod7.z.boolean().optional().default(false)).describe(
|
|
893
|
+
"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."
|
|
894
|
+
)
|
|
967
895
|
},
|
|
968
|
-
async ({ packages, devPackages, scripts, targetDir, runInstall }) => {
|
|
896
|
+
async ({ packages, devPackages, scripts, targetDir, runInstall, includeBaseline }) => {
|
|
897
|
+
const mergedPackages = includeBaseline ? { ...BASELINE_DEPS, ...packages } : { ...packages };
|
|
898
|
+
const mergedDevPackages = includeBaseline ? { ...BASELINE_DEV_DEPS, ...devPackages ?? {} } : devPackages;
|
|
969
899
|
const result = setupPackages({
|
|
970
|
-
packages,
|
|
900
|
+
packages: mergedPackages,
|
|
971
901
|
runInstall,
|
|
972
|
-
...
|
|
902
|
+
...mergedDevPackages !== void 0 ? { devPackages: mergedDevPackages } : {},
|
|
973
903
|
...scripts !== void 0 ? { scripts } : {},
|
|
974
904
|
...targetDir !== void 0 ? { targetDir } : {}
|
|
975
905
|
});
|
|
@@ -1007,8 +937,8 @@ function setupPackages(opts) {
|
|
|
1007
937
|
};
|
|
1008
938
|
try {
|
|
1009
939
|
const cwd = process.cwd();
|
|
1010
|
-
const resolvedTarget = opts.targetDir ?
|
|
1011
|
-
const cwdWithSep = cwd.endsWith(
|
|
940
|
+
const resolvedTarget = opts.targetDir ? import_path2.default.resolve(opts.targetDir) : cwd;
|
|
941
|
+
const cwdWithSep = cwd.endsWith(import_path2.default.sep) ? cwd : cwd + import_path2.default.sep;
|
|
1012
942
|
if (resolvedTarget !== cwd && !resolvedTarget.startsWith(cwdWithSep)) {
|
|
1013
943
|
return {
|
|
1014
944
|
success: false,
|
|
@@ -1021,8 +951,8 @@ function setupPackages(opts) {
|
|
|
1021
951
|
error: `Path traversal rejected: target directory is outside the allowed working directory`
|
|
1022
952
|
};
|
|
1023
953
|
}
|
|
1024
|
-
const preResolvePackageJsonPath =
|
|
1025
|
-
if (!(0,
|
|
954
|
+
const preResolvePackageJsonPath = import_path2.default.join(resolvedTarget, "package.json");
|
|
955
|
+
if (!(0, import_fs2.existsSync)(preResolvePackageJsonPath)) {
|
|
1026
956
|
return {
|
|
1027
957
|
success: false,
|
|
1028
958
|
...emptyResult,
|
|
@@ -1031,7 +961,7 @@ function setupPackages(opts) {
|
|
|
1031
961
|
}
|
|
1032
962
|
let realTarget;
|
|
1033
963
|
try {
|
|
1034
|
-
realTarget = (0,
|
|
964
|
+
realTarget = (0, import_fs2.realpathSync)(resolvedTarget);
|
|
1035
965
|
} catch {
|
|
1036
966
|
return {
|
|
1037
967
|
success: false,
|
|
@@ -1043,8 +973,8 @@ function setupPackages(opts) {
|
|
|
1043
973
|
error: `Could not resolve real path of target directory`
|
|
1044
974
|
};
|
|
1045
975
|
}
|
|
1046
|
-
const realCwd = (0,
|
|
1047
|
-
const realCwdWithSep = realCwd.endsWith(
|
|
976
|
+
const realCwd = (0, import_fs2.realpathSync)(cwd);
|
|
977
|
+
const realCwdWithSep = realCwd.endsWith(import_path2.default.sep) ? realCwd : realCwd + import_path2.default.sep;
|
|
1048
978
|
if (realTarget !== realCwd && !realTarget.startsWith(realCwdWithSep)) {
|
|
1049
979
|
return {
|
|
1050
980
|
success: false,
|
|
@@ -1057,8 +987,8 @@ function setupPackages(opts) {
|
|
|
1057
987
|
error: `Path traversal rejected: target directory resolved to a location outside the allowed working directory`
|
|
1058
988
|
};
|
|
1059
989
|
}
|
|
1060
|
-
const realPackageJsonPath =
|
|
1061
|
-
const pkgStat = (0,
|
|
990
|
+
const realPackageJsonPath = import_path2.default.join(realTarget, "package.json");
|
|
991
|
+
const pkgStat = (0, import_fs2.lstatSync)(realPackageJsonPath);
|
|
1062
992
|
if (pkgStat.isSymbolicLink()) {
|
|
1063
993
|
return {
|
|
1064
994
|
...emptyResult,
|
|
@@ -1117,12 +1047,12 @@ function setupPackages(opts) {
|
|
|
1117
1047
|
}
|
|
1118
1048
|
}
|
|
1119
1049
|
}
|
|
1120
|
-
const raw = (0,
|
|
1121
|
-
const PackageJsonSchema =
|
|
1050
|
+
const raw = (0, import_fs2.readFileSync)(realPackageJsonPath, "utf8");
|
|
1051
|
+
const PackageJsonSchema = import_zod7.z.object({
|
|
1122
1052
|
// Zod v4: z.record(keySchema, valueSchema) — two-arg form required
|
|
1123
|
-
dependencies:
|
|
1124
|
-
devDependencies:
|
|
1125
|
-
scripts:
|
|
1053
|
+
dependencies: import_zod7.z.record(import_zod7.z.string(), import_zod7.z.string()).optional(),
|
|
1054
|
+
devDependencies: import_zod7.z.record(import_zod7.z.string(), import_zod7.z.string()).optional(),
|
|
1055
|
+
scripts: import_zod7.z.record(import_zod7.z.string(), import_zod7.z.string()).optional()
|
|
1126
1056
|
}).passthrough();
|
|
1127
1057
|
const schemaResult = PackageJsonSchema.safeParse(JSON.parse(raw));
|
|
1128
1058
|
if (!schemaResult.success) {
|
|
@@ -1171,12 +1101,12 @@ function setupPackages(opts) {
|
|
|
1171
1101
|
}
|
|
1172
1102
|
}
|
|
1173
1103
|
}
|
|
1174
|
-
(0,
|
|
1104
|
+
(0, import_fs2.writeFileSync)(realPackageJsonPath, JSON.stringify(parsed, null, 2) + "\n");
|
|
1175
1105
|
let installed = false;
|
|
1176
1106
|
let installError;
|
|
1177
1107
|
if (opts.runInstall) {
|
|
1178
1108
|
try {
|
|
1179
|
-
(0,
|
|
1109
|
+
(0, import_child_process.execSync)("pnpm install", { cwd: realTarget, stdio: "pipe", timeout: 6e4 });
|
|
1180
1110
|
installed = true;
|
|
1181
1111
|
} catch (err) {
|
|
1182
1112
|
installed = false;
|
|
@@ -1202,38 +1132,2854 @@ function setupPackages(opts) {
|
|
|
1202
1132
|
}
|
|
1203
1133
|
}
|
|
1204
1134
|
|
|
1205
|
-
//
|
|
1206
|
-
var
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1135
|
+
// src/tools/questions.ts
|
|
1136
|
+
var import_promises = require("fs/promises");
|
|
1137
|
+
var import_node_fs = require("fs");
|
|
1138
|
+
var import_node_path = require("path");
|
|
1139
|
+
var import_zod8 = require("zod");
|
|
1140
|
+
|
|
1141
|
+
// src/question-adapter.ts
|
|
1142
|
+
function truncate(str, maxLength) {
|
|
1143
|
+
if (str.length <= maxLength) return str;
|
|
1144
|
+
return str.substring(0, maxLength - 1) + "\u2026";
|
|
1145
|
+
}
|
|
1146
|
+
function generateHeader(id) {
|
|
1147
|
+
const parts = id.split("-");
|
|
1148
|
+
if (parts.length >= 2) {
|
|
1149
|
+
const prefix = parts[0].toUpperCase().substring(0, 4);
|
|
1150
|
+
const num = parts[1];
|
|
1151
|
+
return truncate(`${prefix}-${num}`, 12);
|
|
1152
|
+
}
|
|
1153
|
+
return truncate(
|
|
1154
|
+
id.toUpperCase().replace(/[^A-Z0-9]/g, "").substring(0, 12),
|
|
1155
|
+
12
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
function generateDefaultOptions(type) {
|
|
1159
|
+
switch (type) {
|
|
1160
|
+
case "confirm":
|
|
1161
|
+
return [
|
|
1162
|
+
{ label: "Yes", description: "Enable or confirm this option" },
|
|
1163
|
+
{ label: "No", description: "Disable or decline this option" }
|
|
1164
|
+
];
|
|
1165
|
+
case "text":
|
|
1166
|
+
return [
|
|
1167
|
+
{ label: "Specify", description: "I will provide a specific value" },
|
|
1168
|
+
{ label: "Skip", description: "Use default or skip this question" }
|
|
1169
|
+
];
|
|
1170
|
+
default:
|
|
1171
|
+
return [
|
|
1172
|
+
{ label: "Option 1", description: "First option" },
|
|
1173
|
+
{ label: "Option 2", description: "Second option" }
|
|
1174
|
+
];
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
function adaptQuestion(q) {
|
|
1178
|
+
const header = generateHeader(q.id);
|
|
1179
|
+
const multiSelect = q.type === "multi-select";
|
|
1180
|
+
let options;
|
|
1181
|
+
if (q.options && q.options.length >= 2) {
|
|
1182
|
+
options = q.options.map((opt) => ({
|
|
1183
|
+
label: truncate(opt.label, 50),
|
|
1184
|
+
description: opt.value !== opt.label ? opt.value : void 0
|
|
1185
|
+
}));
|
|
1186
|
+
} else if (q.options && q.options.length === 1) {
|
|
1187
|
+
options = [
|
|
1188
|
+
...q.options.map((opt) => ({ label: truncate(opt.label, 50), description: opt.value })),
|
|
1189
|
+
{ label: "Other", description: "Specify a different value" }
|
|
1190
|
+
];
|
|
1191
|
+
} else {
|
|
1192
|
+
options = generateDefaultOptions(q.type);
|
|
1193
|
+
}
|
|
1194
|
+
if (options.length < 2) {
|
|
1195
|
+
options.push({ label: "Other", description: "Alternative option" });
|
|
1196
|
+
}
|
|
1197
|
+
options = options.slice(0, 6);
|
|
1198
|
+
return {
|
|
1199
|
+
question: q.question + (q.help ? `
|
|
1200
|
+
|
|
1201
|
+
${q.help}` : ""),
|
|
1202
|
+
header,
|
|
1203
|
+
multi_select: multiSelect,
|
|
1204
|
+
options
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
function adaptQuestions(questions, answers = {}) {
|
|
1208
|
+
const adapted = [];
|
|
1209
|
+
for (const q of questions) {
|
|
1210
|
+
if (q.dependsOn) {
|
|
1211
|
+
const dependsAnswer = answers[q.dependsOn.questionId];
|
|
1212
|
+
if (dependsAnswer === void 0) {
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
const expectedValues = Array.isArray(q.dependsOn.value) ? q.dependsOn.value : [q.dependsOn.value];
|
|
1216
|
+
const answerValue = Array.isArray(dependsAnswer) ? dependsAnswer[0] : dependsAnswer;
|
|
1217
|
+
if (!expectedValues.includes(answerValue)) {
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
adapted.push(adaptQuestion(q));
|
|
1222
|
+
}
|
|
1223
|
+
return adapted;
|
|
1224
|
+
}
|
|
1225
|
+
function parseLLMQuestionsResponse(text) {
|
|
1226
|
+
let jsonStr = text;
|
|
1227
|
+
jsonStr = jsonStr.replace(/```(?:json|javascript)?\s*/gi, "");
|
|
1228
|
+
jsonStr = jsonStr.replace(/```\s*$/gm, "");
|
|
1229
|
+
const firstBrace = jsonStr.indexOf("{");
|
|
1230
|
+
const firstBracket = jsonStr.indexOf("[");
|
|
1231
|
+
let start = -1;
|
|
1232
|
+
if (firstBrace !== -1 && firstBracket !== -1) {
|
|
1233
|
+
start = Math.min(firstBrace, firstBracket);
|
|
1234
|
+
} else if (firstBrace !== -1) {
|
|
1235
|
+
start = firstBrace;
|
|
1236
|
+
} else if (firstBracket !== -1) {
|
|
1237
|
+
start = firstBracket;
|
|
1238
|
+
}
|
|
1239
|
+
if (start === -1) {
|
|
1240
|
+
throw new Error("No JSON found in response");
|
|
1241
|
+
}
|
|
1242
|
+
jsonStr = jsonStr.substring(start);
|
|
1243
|
+
const lastBrace = jsonStr.lastIndexOf("}");
|
|
1244
|
+
const lastBracket = jsonStr.lastIndexOf("]");
|
|
1245
|
+
const end = Math.max(lastBrace, lastBracket);
|
|
1246
|
+
if (end === -1) {
|
|
1247
|
+
throw new Error("Invalid JSON structure");
|
|
1248
|
+
}
|
|
1249
|
+
jsonStr = jsonStr.substring(0, end + 1);
|
|
1250
|
+
jsonStr = jsonStr.replace(/,(\s*[}\]])/g, "$1");
|
|
1251
|
+
jsonStr = jsonStr.replace(/'/g, '"');
|
|
1252
|
+
const parsed = JSON.parse(jsonStr);
|
|
1253
|
+
let questions;
|
|
1254
|
+
if (Array.isArray(parsed)) {
|
|
1255
|
+
questions = parsed;
|
|
1256
|
+
} else if (parsed.questions && Array.isArray(parsed.questions)) {
|
|
1257
|
+
questions = parsed.questions;
|
|
1258
|
+
} else if (parsed.data && Array.isArray(parsed.data.questions)) {
|
|
1259
|
+
questions = parsed.data.questions;
|
|
1260
|
+
} else {
|
|
1261
|
+
throw new Error("No questions array found in response");
|
|
1262
|
+
}
|
|
1263
|
+
function sanitize(obj) {
|
|
1264
|
+
const sanitized = {};
|
|
1265
|
+
for (const key of Object.keys(obj)) {
|
|
1266
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
const val = obj[key];
|
|
1270
|
+
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
1271
|
+
sanitized[key] = sanitize(val);
|
|
1272
|
+
} else if (Array.isArray(val)) {
|
|
1273
|
+
sanitized[key] = val.map(
|
|
1274
|
+
(item) => item && typeof item === "object" && !Array.isArray(item) ? sanitize(item) : item
|
|
1275
|
+
);
|
|
1276
|
+
} else {
|
|
1277
|
+
sanitized[key] = val;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
return sanitized;
|
|
1281
|
+
}
|
|
1282
|
+
questions = questions.map((q) => {
|
|
1283
|
+
if (q && typeof q === "object") {
|
|
1284
|
+
return sanitize(q);
|
|
1285
|
+
}
|
|
1286
|
+
return q;
|
|
1287
|
+
});
|
|
1288
|
+
return questions;
|
|
1289
|
+
}
|
|
1290
|
+
function answersToManifestFormat(answers, questions) {
|
|
1291
|
+
const result = {};
|
|
1292
|
+
for (const answer of answers) {
|
|
1293
|
+
const headerLower = answer.question_header.toLowerCase();
|
|
1294
|
+
const question = questions.find((q) => {
|
|
1295
|
+
const qHeader = generateHeader(q.id).toLowerCase();
|
|
1296
|
+
return qHeader === headerLower || q.id.toLowerCase().includes(headerLower);
|
|
1297
|
+
});
|
|
1298
|
+
if (!question) {
|
|
1299
|
+
const matched = questions.find((q) => {
|
|
1300
|
+
const qHeader = generateHeader(q.id).toLowerCase();
|
|
1301
|
+
return qHeader.startsWith(headerLower.split("-")[0]);
|
|
1302
|
+
});
|
|
1303
|
+
if (matched) {
|
|
1304
|
+
result[matched.id] = answer.selected_options[0] || "";
|
|
1305
|
+
}
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
if (question.type === "multi-select" || answer.selected_options.length > 1) {
|
|
1309
|
+
result[question.id] = answer.selected_options;
|
|
1310
|
+
} else if (question.type === "confirm") {
|
|
1311
|
+
result[question.id] = answer.selected_options[0] === "Yes";
|
|
1312
|
+
} else {
|
|
1313
|
+
if (answer.other_text) {
|
|
1314
|
+
result[question.id] = answer.other_text;
|
|
1315
|
+
} else if (answer.selected_options.length > 0) {
|
|
1316
|
+
const option = question.options?.find((o) => o.label === answer.selected_options[0]);
|
|
1317
|
+
result[question.id] = option?.value ?? answer.selected_options[0];
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return result;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// src/tools/questions.ts
|
|
1325
|
+
var ManifestQuestionSchema = import_zod8.z.object({
|
|
1326
|
+
id: import_zod8.z.string(),
|
|
1327
|
+
question: import_zod8.z.string(),
|
|
1328
|
+
type: import_zod8.z.enum(["text", "select", "multi-select", "confirm"]),
|
|
1329
|
+
required: import_zod8.z.boolean().optional(),
|
|
1330
|
+
options: import_zod8.z.array(
|
|
1331
|
+
import_zod8.z.object({
|
|
1332
|
+
label: import_zod8.z.string(),
|
|
1333
|
+
value: import_zod8.z.string()
|
|
1334
|
+
})
|
|
1335
|
+
).optional(),
|
|
1336
|
+
default: import_zod8.z.union([import_zod8.z.string(), import_zod8.z.boolean(), import_zod8.z.array(import_zod8.z.string())]).optional(),
|
|
1337
|
+
help: import_zod8.z.string().optional(),
|
|
1338
|
+
dependsOn: import_zod8.z.object({
|
|
1339
|
+
questionId: import_zod8.z.string(),
|
|
1340
|
+
value: import_zod8.z.union([import_zod8.z.string(), import_zod8.z.array(import_zod8.z.string())])
|
|
1341
|
+
}).optional()
|
|
1342
|
+
});
|
|
1343
|
+
function registerQuestionTools(server2) {
|
|
1344
|
+
server2.tool(
|
|
1345
|
+
"stackwright_pro_present_phase_questions",
|
|
1346
|
+
"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.",
|
|
1347
|
+
{
|
|
1348
|
+
phase: import_zod8.z.string().describe('Phase name for display context, e.g. "designer", "api", "auth"'),
|
|
1349
|
+
questions: jsonCoerce(import_zod8.z.array(ManifestQuestionSchema).optional()).describe(
|
|
1350
|
+
"Questions in Question Manifest format. If omitted, questions are read from .stackwright/question-manifest.json using the phase name."
|
|
1351
|
+
),
|
|
1352
|
+
answers: jsonCoerce(
|
|
1353
|
+
import_zod8.z.record(import_zod8.z.string(), import_zod8.z.union([import_zod8.z.string(), import_zod8.z.array(import_zod8.z.string()), import_zod8.z.boolean()])).optional()
|
|
1354
|
+
).describe("Previously collected answers used to resolve dependsOn conditions")
|
|
1355
|
+
},
|
|
1356
|
+
async ({ phase, questions, answers }) => {
|
|
1357
|
+
let resolvedQuestions;
|
|
1358
|
+
const SAFE_PHASE = /^[a-z][a-z0-9-]{0,30}$/;
|
|
1359
|
+
if (!SAFE_PHASE.test(phase)) {
|
|
1360
|
+
return {
|
|
1361
|
+
content: [
|
|
1362
|
+
{
|
|
1363
|
+
type: "text",
|
|
1364
|
+
text: JSON.stringify({
|
|
1365
|
+
error: `Invalid phase name: "${phase.slice(0, 50)}". Must be lowercase alphanumeric with hyphens, max 31 chars.`
|
|
1366
|
+
})
|
|
1367
|
+
}
|
|
1368
|
+
],
|
|
1369
|
+
isError: true
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
if (questions && questions.length > 0) {
|
|
1373
|
+
resolvedQuestions = questions;
|
|
1374
|
+
} else {
|
|
1375
|
+
const phaseQuestionPath = (0, import_node_path.join)(process.cwd(), ".stackwright", "questions", `${phase}.json`);
|
|
1376
|
+
try {
|
|
1377
|
+
const raw = await (0, import_promises.readFile)(phaseQuestionPath, "utf-8");
|
|
1378
|
+
const phaseData = JSON.parse(raw);
|
|
1379
|
+
resolvedQuestions = phaseData.questions ?? [];
|
|
1380
|
+
} catch {
|
|
1381
|
+
try {
|
|
1382
|
+
const manifestPath = (0, import_node_path.join)(process.cwd(), ".stackwright", "question-manifest.json");
|
|
1383
|
+
const raw = await (0, import_promises.readFile)(manifestPath, "utf-8");
|
|
1384
|
+
const manifest = JSON.parse(raw);
|
|
1385
|
+
const phaseData = manifest.phases.find((p) => p.phase === phase);
|
|
1386
|
+
resolvedQuestions = phaseData?.questions ?? [];
|
|
1387
|
+
} catch (err) {
|
|
1388
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1389
|
+
return {
|
|
1390
|
+
content: [
|
|
1391
|
+
{
|
|
1392
|
+
type: "text",
|
|
1393
|
+
text: JSON.stringify({
|
|
1394
|
+
error: `Could not read question manifest for phase "${phase}": ${msg}`,
|
|
1395
|
+
hint: "Write the manifest first, or pass questions directly to this tool."
|
|
1396
|
+
})
|
|
1397
|
+
}
|
|
1398
|
+
],
|
|
1399
|
+
isError: true
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
const adapted = adaptQuestions(resolvedQuestions, answers ?? {});
|
|
1405
|
+
if (adapted.length === 0) {
|
|
1406
|
+
return {
|
|
1407
|
+
content: [
|
|
1408
|
+
{
|
|
1409
|
+
type: "text",
|
|
1410
|
+
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.`
|
|
1411
|
+
},
|
|
1412
|
+
{
|
|
1413
|
+
type: "text",
|
|
1414
|
+
// Empty array — second block always present so the foreman's two-block contract holds
|
|
1415
|
+
text: JSON.stringify([])
|
|
1416
|
+
}
|
|
1417
|
+
],
|
|
1418
|
+
isError: false
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
return {
|
|
1422
|
+
content: [
|
|
1423
|
+
{
|
|
1424
|
+
type: "text",
|
|
1425
|
+
text: `Adapted ${adapted.length} questions for phase "${phase}". Pass the JSON array below DIRECTLY to ask_user_question as the "questions" parameter. Do NOT JSON.stringify() it. Do NOT wrap it in an object. Pass the parsed array value as-is.`
|
|
1426
|
+
},
|
|
1427
|
+
{
|
|
1428
|
+
type: "text",
|
|
1429
|
+
text: JSON.stringify(adapted)
|
|
1430
|
+
}
|
|
1431
|
+
],
|
|
1432
|
+
isError: false
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
);
|
|
1436
|
+
server2.tool(
|
|
1437
|
+
"stackwright_pro_get_next_question",
|
|
1438
|
+
"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.",
|
|
1439
|
+
{ phase: import_zod8.z.string().describe('Phase name e.g. "designer", "api", "auth"') },
|
|
1440
|
+
async ({ phase }) => {
|
|
1441
|
+
const SAFE_PHASE = /^[a-z][a-z0-9-]{0,30}$/;
|
|
1442
|
+
if (!SAFE_PHASE.test(phase)) {
|
|
1443
|
+
return {
|
|
1444
|
+
content: [
|
|
1445
|
+
{
|
|
1446
|
+
type: "text",
|
|
1447
|
+
text: JSON.stringify({ error: `Invalid phase name: "${phase.slice(0, 50)}"` })
|
|
1448
|
+
}
|
|
1449
|
+
],
|
|
1450
|
+
isError: true
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
const cwd = process.cwd();
|
|
1454
|
+
const questionsPath = (0, import_node_path.join)(cwd, ".stackwright", "questions", `${phase}.json`);
|
|
1455
|
+
let questions = [];
|
|
1456
|
+
try {
|
|
1457
|
+
const raw = await (0, import_promises.readFile)(questionsPath, "utf-8");
|
|
1458
|
+
const parsed = JSON.parse(raw);
|
|
1459
|
+
questions = parsed.questions ?? [];
|
|
1460
|
+
} catch {
|
|
1461
|
+
return { content: [{ type: "text", text: JSON.stringify({ done: true }) }] };
|
|
1462
|
+
}
|
|
1463
|
+
if (questions.length === 0) {
|
|
1464
|
+
return { content: [{ type: "text", text: JSON.stringify({ done: true }) }] };
|
|
1465
|
+
}
|
|
1466
|
+
const answersPath = (0, import_node_path.join)(cwd, ".stackwright", "answers", `${phase}.json`);
|
|
1467
|
+
let answeredIds = /* @__PURE__ */ new Set();
|
|
1468
|
+
try {
|
|
1469
|
+
const raw = await (0, import_promises.readFile)(answersPath, "utf-8");
|
|
1470
|
+
const parsed = JSON.parse(raw);
|
|
1471
|
+
answeredIds = new Set(Object.keys(parsed.answers ?? {}));
|
|
1472
|
+
} catch {
|
|
1473
|
+
}
|
|
1474
|
+
const next = questions.find((q) => !answeredIds.has(q.id));
|
|
1475
|
+
if (!next) {
|
|
1476
|
+
return { content: [{ type: "text", text: JSON.stringify({ done: true }) }] };
|
|
1477
|
+
}
|
|
1478
|
+
return {
|
|
1479
|
+
content: [
|
|
1480
|
+
{
|
|
1481
|
+
type: "text",
|
|
1482
|
+
text: JSON.stringify({
|
|
1483
|
+
done: false,
|
|
1484
|
+
questionId: next.id,
|
|
1485
|
+
question: next.question,
|
|
1486
|
+
type: next.type,
|
|
1487
|
+
options: next.options ?? null,
|
|
1488
|
+
help: next.help ?? null,
|
|
1489
|
+
index: questions.indexOf(next) + 1,
|
|
1490
|
+
total: questions.length
|
|
1491
|
+
})
|
|
1492
|
+
}
|
|
1493
|
+
]
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
);
|
|
1497
|
+
server2.tool(
|
|
1498
|
+
"stackwright_pro_record_answer",
|
|
1499
|
+
"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.",
|
|
1500
|
+
{
|
|
1501
|
+
phase: import_zod8.z.string().describe('Phase name e.g. "designer"'),
|
|
1502
|
+
questionId: import_zod8.z.string().describe('The question ID from get_next_question, e.g. "designer-1"'),
|
|
1503
|
+
answer: import_zod8.z.string().describe("The user's free-text answer")
|
|
1504
|
+
},
|
|
1505
|
+
async ({ phase, questionId, answer }) => {
|
|
1506
|
+
const SAFE_PHASE = /^[a-z][a-z0-9-]{0,30}$/;
|
|
1507
|
+
if (!SAFE_PHASE.test(phase)) {
|
|
1508
|
+
return {
|
|
1509
|
+
content: [
|
|
1510
|
+
{ type: "text", text: JSON.stringify({ error: "Invalid phase name" }) }
|
|
1511
|
+
],
|
|
1512
|
+
isError: true
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/i.test(questionId)) {
|
|
1516
|
+
return {
|
|
1517
|
+
content: [
|
|
1518
|
+
{ type: "text", text: JSON.stringify({ error: "Invalid questionId" }) }
|
|
1519
|
+
],
|
|
1520
|
+
isError: true
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
const safeAnswer = answer.slice(0, 2e3);
|
|
1524
|
+
const cwd = process.cwd();
|
|
1525
|
+
const answersDir = (0, import_node_path.join)(cwd, ".stackwright", "answers");
|
|
1526
|
+
const answersPath = (0, import_node_path.join)(answersDir, `${phase}.json`);
|
|
1527
|
+
if ((0, import_node_fs.existsSync)(answersDir) && (0, import_node_fs.lstatSync)(answersDir).isSymbolicLink()) {
|
|
1528
|
+
return {
|
|
1529
|
+
content: [
|
|
1530
|
+
{
|
|
1531
|
+
type: "text",
|
|
1532
|
+
text: JSON.stringify({ error: "answers dir is a symlink \u2014 refusing to write" })
|
|
1533
|
+
}
|
|
1534
|
+
],
|
|
1535
|
+
isError: true
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
(0, import_node_fs.mkdirSync)(answersDir, { recursive: true });
|
|
1539
|
+
let existing = {
|
|
1540
|
+
version: "1.0",
|
|
1541
|
+
phase,
|
|
1542
|
+
answers: {}
|
|
1543
|
+
};
|
|
1544
|
+
try {
|
|
1545
|
+
if ((0, import_node_fs.existsSync)(answersPath)) {
|
|
1546
|
+
if ((0, import_node_fs.lstatSync)(answersPath).isSymbolicLink()) {
|
|
1547
|
+
return {
|
|
1548
|
+
content: [
|
|
1549
|
+
{
|
|
1550
|
+
type: "text",
|
|
1551
|
+
text: JSON.stringify({ error: "answers file is a symlink \u2014 refusing to write" })
|
|
1552
|
+
}
|
|
1553
|
+
],
|
|
1554
|
+
isError: true
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
const raw = await (0, import_promises.readFile)(answersPath, "utf-8");
|
|
1558
|
+
existing = JSON.parse(raw);
|
|
1559
|
+
}
|
|
1560
|
+
} catch {
|
|
1561
|
+
}
|
|
1562
|
+
existing.answers[questionId] = safeAnswer;
|
|
1563
|
+
existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1564
|
+
const tmp = `${answersPath}.tmp`;
|
|
1565
|
+
await (0, import_promises.writeFile)(tmp, JSON.stringify(existing, null, 2), "utf-8");
|
|
1566
|
+
(0, import_node_fs.renameSync)(tmp, answersPath);
|
|
1567
|
+
return {
|
|
1568
|
+
content: [
|
|
1569
|
+
{ type: "text", text: JSON.stringify({ recorded: true, phase, questionId }) }
|
|
1570
|
+
]
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// src/tools/orchestration.ts
|
|
1577
|
+
var import_zod9 = require("zod");
|
|
1578
|
+
var import_fs3 = require("fs");
|
|
1579
|
+
var import_path3 = require("path");
|
|
1580
|
+
var OTTER_NAME_TO_PHASE = [
|
|
1581
|
+
["designer", "designer"],
|
|
1582
|
+
["theme", "theme"],
|
|
1583
|
+
["api", "api"],
|
|
1584
|
+
["auth", "auth"],
|
|
1585
|
+
["dashboard", "dashboard"],
|
|
1586
|
+
["data", "data"],
|
|
1587
|
+
["page", "pages"],
|
|
1588
|
+
["workflow", "workflow"]
|
|
1589
|
+
];
|
|
1590
|
+
var PHASE_TO_OTTER = {
|
|
1591
|
+
designer: "stackwright-pro-designer-otter",
|
|
1592
|
+
theme: "stackwright-pro-theme-otter",
|
|
1593
|
+
api: "stackwright-pro-api-otter",
|
|
1594
|
+
auth: "stackwright-pro-auth-otter",
|
|
1595
|
+
pages: "stackwright-pro-page-otter",
|
|
1596
|
+
dashboard: "stackwright-pro-dashboard-otter",
|
|
1597
|
+
data: "stackwright-pro-data-otter",
|
|
1598
|
+
workflow: "stackwright-pro-workflow-otter"
|
|
1599
|
+
};
|
|
1600
|
+
function detectPhase(otterName) {
|
|
1601
|
+
const lower = otterName.toLowerCase();
|
|
1602
|
+
for (const [keyword, phase] of OTTER_NAME_TO_PHASE) {
|
|
1603
|
+
if (lower.includes(keyword)) return phase;
|
|
1604
|
+
}
|
|
1605
|
+
return lower.replace(/^stackwright-pro-/, "").replace(/-otter$/, "");
|
|
1606
|
+
}
|
|
1607
|
+
function handleParseOtterResponse(input) {
|
|
1608
|
+
const phase = detectPhase(input.otterName);
|
|
1609
|
+
try {
|
|
1610
|
+
const questions = parseLLMQuestionsResponse(input.responseText);
|
|
1611
|
+
return {
|
|
1612
|
+
result: { phase, otter: input.otterName, questions },
|
|
1613
|
+
isError: false
|
|
1614
|
+
};
|
|
1615
|
+
} catch (err) {
|
|
1616
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1617
|
+
return {
|
|
1618
|
+
result: {
|
|
1619
|
+
error: true,
|
|
1620
|
+
otterName: input.otterName,
|
|
1621
|
+
phase,
|
|
1622
|
+
questions: [],
|
|
1623
|
+
parseError: message
|
|
1624
|
+
},
|
|
1625
|
+
isError: true
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
function handleSaveManifest(input) {
|
|
1630
|
+
const cwd = input._cwd ?? process.cwd();
|
|
1631
|
+
const dir = (0, import_path3.join)(cwd, ".stackwright");
|
|
1632
|
+
const filePath = (0, import_path3.join)(dir, "question-manifest.json");
|
|
1633
|
+
try {
|
|
1634
|
+
(0, import_fs3.mkdirSync)(dir, { recursive: true });
|
|
1635
|
+
if ((0, import_fs3.existsSync)(filePath)) {
|
|
1636
|
+
const stat = (0, import_fs3.lstatSync)(filePath);
|
|
1637
|
+
if (stat.isSymbolicLink()) {
|
|
1638
|
+
const message = `Refusing to write to symlink: ${filePath}`;
|
|
1639
|
+
return {
|
|
1640
|
+
text: JSON.stringify({ success: false, error: message }),
|
|
1641
|
+
isError: true
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
const manifest = {
|
|
1646
|
+
version: "1.0",
|
|
1647
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1648
|
+
phases: input.phases
|
|
1649
|
+
};
|
|
1650
|
+
(0, import_fs3.writeFileSync)(filePath, JSON.stringify(manifest, null, 2) + "\n");
|
|
1651
|
+
return {
|
|
1652
|
+
text: JSON.stringify({ success: true, path: filePath, phaseCount: input.phases.length }),
|
|
1653
|
+
isError: false
|
|
1654
|
+
};
|
|
1655
|
+
} catch (err) {
|
|
1656
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1657
|
+
return {
|
|
1658
|
+
text: JSON.stringify({ success: false, error: message }),
|
|
1659
|
+
isError: true
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
function handleSavePhaseAnswers(input) {
|
|
1664
|
+
const cwd = input._cwd ?? process.cwd();
|
|
1665
|
+
const dir = (0, import_path3.join)(cwd, ".stackwright", "answers");
|
|
1666
|
+
const filePath = (0, import_path3.join)(dir, `${input.phase}.json`);
|
|
1667
|
+
try {
|
|
1668
|
+
(0, import_fs3.mkdirSync)(dir, { recursive: true });
|
|
1669
|
+
let answers;
|
|
1670
|
+
if (input.questions && input.questions.length > 0) {
|
|
1671
|
+
answers = answersToManifestFormat(input.rawAnswers, input.questions);
|
|
1672
|
+
} else {
|
|
1673
|
+
answers = Object.fromEntries(
|
|
1674
|
+
input.rawAnswers.map((a) => [a.question_header, a.selected_options[0] ?? ""])
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
const payload = {
|
|
1678
|
+
version: "1.0",
|
|
1679
|
+
phase: input.phase,
|
|
1680
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1681
|
+
answers
|
|
1682
|
+
};
|
|
1683
|
+
if ((0, import_fs3.existsSync)(filePath)) {
|
|
1684
|
+
const stat = (0, import_fs3.lstatSync)(filePath);
|
|
1685
|
+
if (stat.isSymbolicLink()) {
|
|
1686
|
+
const message = `Refusing to write to symlink: ${filePath}`;
|
|
1687
|
+
return {
|
|
1688
|
+
text: JSON.stringify({ success: false, error: message }),
|
|
1689
|
+
isError: true
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
(0, import_fs3.writeFileSync)(filePath, JSON.stringify(payload, null, 2) + "\n");
|
|
1694
|
+
return {
|
|
1695
|
+
text: JSON.stringify({
|
|
1696
|
+
success: true,
|
|
1697
|
+
path: filePath,
|
|
1698
|
+
answersCount: Object.keys(answers).length
|
|
1699
|
+
}),
|
|
1700
|
+
isError: false
|
|
1701
|
+
};
|
|
1702
|
+
} catch (err) {
|
|
1703
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1704
|
+
return {
|
|
1705
|
+
text: JSON.stringify({ success: false, error: message }),
|
|
1706
|
+
isError: true
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
function handleReadPhaseAnswers(input) {
|
|
1711
|
+
const cwd = input._cwd ?? process.cwd();
|
|
1712
|
+
const filePath = (0, import_path3.join)(cwd, ".stackwright", "answers", `${input.phase}.json`);
|
|
1713
|
+
if (!(0, import_fs3.existsSync)(filePath)) {
|
|
1714
|
+
return {
|
|
1715
|
+
text: JSON.stringify({ missing: true, phase: input.phase }),
|
|
1716
|
+
isError: false
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
try {
|
|
1720
|
+
const raw = (0, import_fs3.readFileSync)(filePath, "utf8");
|
|
1721
|
+
const parsed = JSON.parse(raw);
|
|
1722
|
+
return { text: JSON.stringify(parsed), isError: false };
|
|
1723
|
+
} catch (err) {
|
|
1724
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1725
|
+
return {
|
|
1726
|
+
text: JSON.stringify({ error: true, phase: input.phase, readError: message }),
|
|
1727
|
+
isError: true
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
function handleGetOtterName(input) {
|
|
1732
|
+
const normalised = input.phase.toLowerCase().trim();
|
|
1733
|
+
const otterName = PHASE_TO_OTTER[normalised];
|
|
1734
|
+
if (!otterName) {
|
|
1735
|
+
return {
|
|
1736
|
+
text: JSON.stringify({ error: true, message: `Unknown phase: ${input.phase}` }),
|
|
1737
|
+
isError: true
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
return {
|
|
1741
|
+
text: JSON.stringify({ phase: normalised, otterName }),
|
|
1742
|
+
isError: false
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
function handleSaveBuildContext(input) {
|
|
1746
|
+
const cwd = input._cwd ?? process.cwd();
|
|
1747
|
+
const dir = (0, import_path3.join)(cwd, ".stackwright");
|
|
1748
|
+
const filePath = (0, import_path3.join)(dir, "build-context.json");
|
|
1749
|
+
try {
|
|
1750
|
+
(0, import_fs3.mkdirSync)(dir, { recursive: true });
|
|
1751
|
+
if ((0, import_fs3.existsSync)(filePath)) {
|
|
1752
|
+
const stat = (0, import_fs3.lstatSync)(filePath);
|
|
1753
|
+
if (stat.isSymbolicLink()) {
|
|
1754
|
+
return {
|
|
1755
|
+
text: JSON.stringify({
|
|
1756
|
+
success: false,
|
|
1757
|
+
error: `Refusing to write to symlink: ${filePath}`
|
|
1758
|
+
}),
|
|
1759
|
+
isError: true
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
const payload = {
|
|
1764
|
+
version: "1.0",
|
|
1765
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1766
|
+
buildContext: input.buildContext
|
|
1767
|
+
};
|
|
1768
|
+
(0, import_fs3.writeFileSync)(filePath, JSON.stringify(payload, null, 2) + "\n");
|
|
1769
|
+
return {
|
|
1770
|
+
text: JSON.stringify({ success: true, path: filePath }),
|
|
1771
|
+
isError: false
|
|
1772
|
+
};
|
|
1773
|
+
} catch (err) {
|
|
1774
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1775
|
+
return {
|
|
1776
|
+
text: JSON.stringify({ success: false, error: message }),
|
|
1777
|
+
isError: true
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
function registerOrchestrationTools(server2) {
|
|
1782
|
+
server2.tool(
|
|
1783
|
+
"stackwright_pro_save_build_context",
|
|
1784
|
+
`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.`,
|
|
1785
|
+
{
|
|
1786
|
+
buildContext: import_zod9.z.string().describe("Free-text description of what the user wants to build")
|
|
1787
|
+
},
|
|
1788
|
+
async ({ buildContext }) => {
|
|
1789
|
+
const { text, isError } = handleSaveBuildContext({ buildContext });
|
|
1790
|
+
return {
|
|
1791
|
+
content: [{ type: "text", text }],
|
|
1792
|
+
isError
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
);
|
|
1796
|
+
server2.tool(
|
|
1797
|
+
"stackwright_pro_parse_otter_response",
|
|
1798
|
+
"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.",
|
|
1799
|
+
{
|
|
1800
|
+
otterName: import_zod9.z.string().describe('The agent name, e.g. "stackwright-pro-api-otter"'),
|
|
1801
|
+
responseText: import_zod9.z.string().describe("Raw text response from the otter's QUESTION_COLLECTION_MODE invocation")
|
|
1802
|
+
},
|
|
1803
|
+
async ({ otterName, responseText }) => {
|
|
1804
|
+
const { result, isError } = handleParseOtterResponse({ otterName, responseText });
|
|
1805
|
+
return {
|
|
1806
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
1807
|
+
isError
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
);
|
|
1811
|
+
server2.tool(
|
|
1812
|
+
"stackwright_pro_save_manifest",
|
|
1813
|
+
"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.",
|
|
1814
|
+
{
|
|
1815
|
+
phases: jsonCoerce(
|
|
1816
|
+
import_zod9.z.array(
|
|
1817
|
+
import_zod9.z.object({
|
|
1818
|
+
phase: import_zod9.z.string(),
|
|
1819
|
+
otter: import_zod9.z.string(),
|
|
1820
|
+
questions: import_zod9.z.array(import_zod9.z.any()),
|
|
1821
|
+
requiredPackages: import_zod9.z.object({
|
|
1822
|
+
dependencies: import_zod9.z.record(import_zod9.z.string(), import_zod9.z.string()).optional(),
|
|
1823
|
+
devPackages: import_zod9.z.record(import_zod9.z.string(), import_zod9.z.string()).optional()
|
|
1824
|
+
}).optional()
|
|
1825
|
+
})
|
|
1826
|
+
)
|
|
1827
|
+
).describe("Array of parsed phase objects from stackwright_pro_parse_otter_response")
|
|
1828
|
+
},
|
|
1829
|
+
async ({ phases }) => {
|
|
1830
|
+
const { text, isError } = handleSaveManifest({ phases });
|
|
1831
|
+
return {
|
|
1832
|
+
content: [{ type: "text", text }],
|
|
1833
|
+
isError
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
);
|
|
1837
|
+
server2.tool(
|
|
1838
|
+
"stackwright_pro_save_phase_answers",
|
|
1839
|
+
"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.",
|
|
1840
|
+
{
|
|
1841
|
+
phase: import_zod9.z.string().describe('Phase name, e.g. "designer"'),
|
|
1842
|
+
rawAnswers: jsonCoerce(
|
|
1843
|
+
import_zod9.z.array(
|
|
1844
|
+
import_zod9.z.object({
|
|
1845
|
+
question_header: import_zod9.z.string(),
|
|
1846
|
+
selected_options: import_zod9.z.array(import_zod9.z.string()),
|
|
1847
|
+
other_text: import_zod9.z.string().nullable().optional()
|
|
1848
|
+
})
|
|
1849
|
+
)
|
|
1850
|
+
).describe(
|
|
1851
|
+
"Answers as returned by ask_user_question \u2014 pass the native array, not a JSON string"
|
|
1852
|
+
),
|
|
1853
|
+
questions: jsonCoerce(import_zod9.z.array(import_zod9.z.any()).optional()).describe(
|
|
1854
|
+
"Original manifest questions for label\u2192value reverse-mapping \u2014 pass the native array, not a JSON string"
|
|
1855
|
+
)
|
|
1856
|
+
},
|
|
1857
|
+
async ({ phase, rawAnswers, questions }) => {
|
|
1858
|
+
const { text, isError } = handleSavePhaseAnswers({
|
|
1859
|
+
phase,
|
|
1860
|
+
rawAnswers,
|
|
1861
|
+
...questions && questions.length > 0 ? { questions } : {}
|
|
1862
|
+
});
|
|
1863
|
+
return {
|
|
1864
|
+
content: [{ type: "text", text }],
|
|
1865
|
+
isError
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
);
|
|
1869
|
+
server2.tool(
|
|
1870
|
+
"stackwright_pro_read_phase_answers",
|
|
1871
|
+
"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.",
|
|
1872
|
+
{
|
|
1873
|
+
phase: import_zod9.z.string().describe('Phase name, e.g. "designer"')
|
|
1874
|
+
},
|
|
1875
|
+
async ({ phase }) => {
|
|
1876
|
+
const { text, isError } = handleReadPhaseAnswers({ phase });
|
|
1877
|
+
return {
|
|
1878
|
+
content: [{ type: "text", text }],
|
|
1879
|
+
isError
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
);
|
|
1883
|
+
server2.tool(
|
|
1884
|
+
"stackwright_pro_get_otter_name",
|
|
1885
|
+
"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.",
|
|
1886
|
+
{
|
|
1887
|
+
phase: import_zod9.z.string().describe('Phase name, e.g. "designer", "api", "pages"')
|
|
1888
|
+
},
|
|
1889
|
+
async ({ phase }) => {
|
|
1890
|
+
const { text, isError } = handleGetOtterName({ phase });
|
|
1891
|
+
return {
|
|
1892
|
+
content: [{ type: "text", text }],
|
|
1893
|
+
isError
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
);
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// src/tools/pipeline.ts
|
|
1900
|
+
var import_zod10 = require("zod");
|
|
1901
|
+
var import_fs4 = require("fs");
|
|
1902
|
+
var import_path4 = require("path");
|
|
1903
|
+
var import_types = require("@stackwright-pro/types");
|
|
1904
|
+
var PHASE_ORDER = [
|
|
1905
|
+
"designer",
|
|
1906
|
+
"theme",
|
|
1907
|
+
"api",
|
|
1908
|
+
"data",
|
|
1909
|
+
"workflow",
|
|
1910
|
+
"pages",
|
|
1911
|
+
"dashboard",
|
|
1912
|
+
"auth"
|
|
1913
|
+
];
|
|
1914
|
+
var PHASE_DEPENDENCIES = {
|
|
1915
|
+
designer: [],
|
|
1916
|
+
theme: ["designer"],
|
|
1917
|
+
api: [],
|
|
1918
|
+
data: ["api"],
|
|
1919
|
+
// workflow: no hard deps — uses service: references with Prism mock fallback
|
|
1920
|
+
// when API artifacts aren't available; roles come from user answers
|
|
1921
|
+
workflow: [],
|
|
1922
|
+
// pages: 'api' is transitive through 'data'; auth removed — page-otter has
|
|
1923
|
+
// graceful fallback and reads role names from workflow artifacts instead
|
|
1924
|
+
pages: ["designer", "theme", "data"],
|
|
1925
|
+
dashboard: ["designer", "theme", "data"],
|
|
1926
|
+
// auth is the terminal phase — reads all available artifacts at runtime;
|
|
1927
|
+
// no hard upstream requirements so it always runs and never gets skipped
|
|
1928
|
+
auth: []
|
|
1929
|
+
};
|
|
1930
|
+
var PHASE_ARTIFACT = {
|
|
1931
|
+
designer: "design-language.json",
|
|
1932
|
+
theme: "theme-tokens.json",
|
|
1933
|
+
api: "api-config.json",
|
|
1934
|
+
auth: "auth-config.json",
|
|
1935
|
+
data: "data-config.json",
|
|
1936
|
+
pages: "pages-manifest.json",
|
|
1937
|
+
dashboard: "dashboard-manifest.json",
|
|
1938
|
+
workflow: "workflow-config.json"
|
|
1939
|
+
};
|
|
1940
|
+
var PHASE_TO_OTTER2 = {
|
|
1941
|
+
designer: "stackwright-pro-designer-otter",
|
|
1942
|
+
theme: "stackwright-pro-theme-otter",
|
|
1943
|
+
api: "stackwright-pro-api-otter",
|
|
1944
|
+
auth: "stackwright-pro-auth-otter",
|
|
1945
|
+
data: "stackwright-pro-data-otter",
|
|
1946
|
+
pages: "stackwright-pro-page-otter",
|
|
1947
|
+
dashboard: "stackwright-pro-dashboard-otter",
|
|
1948
|
+
workflow: "stackwright-pro-workflow-otter"
|
|
1949
|
+
};
|
|
1950
|
+
function isValidPhase(phase) {
|
|
1951
|
+
return PHASE_ORDER.includes(phase);
|
|
1952
|
+
}
|
|
1953
|
+
function defaultPhaseStatus() {
|
|
1954
|
+
return {
|
|
1955
|
+
questionsCollected: false,
|
|
1956
|
+
answered: false,
|
|
1957
|
+
executed: false,
|
|
1958
|
+
artifactWritten: false,
|
|
1959
|
+
retryCount: 0
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
function createDefaultState() {
|
|
1963
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1964
|
+
const phases = {};
|
|
1965
|
+
for (const p of PHASE_ORDER) {
|
|
1966
|
+
phases[p] = defaultPhaseStatus();
|
|
1967
|
+
}
|
|
1968
|
+
return {
|
|
1969
|
+
version: "1.0",
|
|
1970
|
+
currentPhase: PHASE_ORDER[0],
|
|
1971
|
+
status: "setup",
|
|
1972
|
+
phases,
|
|
1973
|
+
startedAt: now,
|
|
1974
|
+
updatedAt: now
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
function statePath(cwd) {
|
|
1978
|
+
return (0, import_path4.join)(cwd, ".stackwright", "pipeline-state.json");
|
|
1979
|
+
}
|
|
1980
|
+
function readState(cwd) {
|
|
1981
|
+
const p = statePath(cwd);
|
|
1982
|
+
if (!(0, import_fs4.existsSync)(p)) return createDefaultState();
|
|
1983
|
+
const raw = JSON.parse(safeReadSync(p));
|
|
1984
|
+
if (typeof raw !== "object" || raw === null || raw.version !== "1.0") {
|
|
1985
|
+
return createDefaultState();
|
|
1986
|
+
}
|
|
1987
|
+
return raw;
|
|
1988
|
+
}
|
|
1989
|
+
function safeWriteSync(filePath, content) {
|
|
1990
|
+
if ((0, import_fs4.existsSync)(filePath)) {
|
|
1991
|
+
const stat = (0, import_fs4.lstatSync)(filePath);
|
|
1992
|
+
if (stat.isSymbolicLink()) {
|
|
1993
|
+
throw new Error(`Refusing to write to symlink: ${filePath}`);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
(0, import_fs4.writeFileSync)(filePath, content);
|
|
1997
|
+
}
|
|
1998
|
+
function safeReadSync(filePath) {
|
|
1999
|
+
if ((0, import_fs4.existsSync)(filePath)) {
|
|
2000
|
+
const stat = (0, import_fs4.lstatSync)(filePath);
|
|
2001
|
+
if (stat.isSymbolicLink()) {
|
|
2002
|
+
throw new Error(`Refusing to read symlink: ${filePath}`);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
return (0, import_fs4.readFileSync)(filePath, "utf-8");
|
|
2006
|
+
}
|
|
2007
|
+
function writeState(cwd, state) {
|
|
2008
|
+
const dir = (0, import_path4.join)(cwd, ".stackwright");
|
|
2009
|
+
(0, import_fs4.mkdirSync)(dir, { recursive: true });
|
|
2010
|
+
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2011
|
+
safeWriteSync(statePath(cwd), JSON.stringify(state, null, 2) + "\n");
|
|
2012
|
+
}
|
|
2013
|
+
function extractJsonFromResponse(text) {
|
|
2014
|
+
let cleaned = text;
|
|
2015
|
+
cleaned = cleaned.replace(/```(?:json)?\s*/gi, "");
|
|
2016
|
+
cleaned = cleaned.replace(/```\s*$/gm, "");
|
|
2017
|
+
const firstBrace = cleaned.indexOf("{");
|
|
2018
|
+
if (firstBrace === -1) throw new Error("No JSON object found in response");
|
|
2019
|
+
cleaned = cleaned.substring(firstBrace);
|
|
2020
|
+
const lastBrace = cleaned.lastIndexOf("}");
|
|
2021
|
+
if (lastBrace === -1) throw new Error("Unclosed JSON object in response");
|
|
2022
|
+
cleaned = cleaned.substring(0, lastBrace + 1);
|
|
2023
|
+
cleaned = cleaned.replace(/,(\s*[}\]])/g, "$1");
|
|
2024
|
+
return JSON.parse(cleaned);
|
|
2025
|
+
}
|
|
2026
|
+
function handleGetPipelineState(_cwd) {
|
|
2027
|
+
const cwd = _cwd ?? process.cwd();
|
|
2028
|
+
try {
|
|
2029
|
+
const state = readState(cwd);
|
|
2030
|
+
return { text: JSON.stringify(state), isError: false };
|
|
2031
|
+
} catch (err) {
|
|
2032
|
+
return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
function handleSetPipelineState(input) {
|
|
2036
|
+
const cwd = input._cwd ?? process.cwd();
|
|
2037
|
+
if (input.phase && !isValidPhase(input.phase)) {
|
|
2038
|
+
return {
|
|
2039
|
+
text: JSON.stringify({
|
|
2040
|
+
error: true,
|
|
2041
|
+
message: `Invalid phase: ${input.phase}. Valid phases are: ${PHASE_ORDER.join(", ")}`
|
|
2042
|
+
}),
|
|
2043
|
+
isError: true
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
const VALID_FIELDS = ["questionsCollected", "answered", "executed", "artifactWritten"];
|
|
2047
|
+
if (input.field && !VALID_FIELDS.includes(input.field)) {
|
|
2048
|
+
return {
|
|
2049
|
+
text: JSON.stringify({
|
|
2050
|
+
error: true,
|
|
2051
|
+
message: `Invalid field: ${input.field}. Valid fields are: ${VALID_FIELDS.join(", ")}`
|
|
2052
|
+
}),
|
|
2053
|
+
isError: true
|
|
2054
|
+
};
|
|
2055
|
+
}
|
|
2056
|
+
try {
|
|
2057
|
+
const state = readState(cwd);
|
|
2058
|
+
if (input.status) {
|
|
2059
|
+
state.status = input.status;
|
|
2060
|
+
}
|
|
2061
|
+
if (input.phase) {
|
|
2062
|
+
const phase = input.phase;
|
|
2063
|
+
if (!state.phases[phase]) {
|
|
2064
|
+
state.phases[phase] = defaultPhaseStatus();
|
|
2065
|
+
}
|
|
2066
|
+
const phaseState = state.phases[phase];
|
|
2067
|
+
if (input.field && input.value !== void 0) {
|
|
2068
|
+
phaseState[input.field] = input.value;
|
|
2069
|
+
}
|
|
2070
|
+
if (input.incrementRetry) {
|
|
2071
|
+
phaseState.retryCount += 1;
|
|
2072
|
+
}
|
|
2073
|
+
state.currentPhase = phase;
|
|
2074
|
+
}
|
|
2075
|
+
writeState(cwd, state);
|
|
2076
|
+
return { text: JSON.stringify(state), isError: false };
|
|
2077
|
+
} catch (err) {
|
|
2078
|
+
return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
function handleCheckExecutionReady(_cwd, phase) {
|
|
2082
|
+
const cwd = _cwd ?? process.cwd();
|
|
2083
|
+
if (phase) {
|
|
2084
|
+
if (!isValidPhase(phase)) {
|
|
2085
|
+
return {
|
|
2086
|
+
text: JSON.stringify({
|
|
2087
|
+
error: true,
|
|
2088
|
+
message: `Invalid phase: ${phase}. Valid phases are: ${PHASE_ORDER.join(", ")}`
|
|
2089
|
+
}),
|
|
2090
|
+
isError: true
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
const answerFile = (0, import_path4.join)(cwd, ".stackwright", "answers", `${phase}.json`);
|
|
2094
|
+
if (!(0, import_fs4.existsSync)(answerFile)) {
|
|
2095
|
+
return {
|
|
2096
|
+
text: JSON.stringify({ ready: false, phase, reason: "Answer file not found" }),
|
|
2097
|
+
isError: false
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
try {
|
|
2101
|
+
const raw = safeReadSync(answerFile);
|
|
2102
|
+
const parsed = JSON.parse(raw);
|
|
2103
|
+
if (typeof parsed["version"] !== "string" || typeof parsed["phase"] !== "string" || typeof parsed["answers"] !== "object" || parsed["answers"] === null) {
|
|
2104
|
+
return {
|
|
2105
|
+
text: JSON.stringify({ ready: false, phase, reason: "Answer file is malformed" }),
|
|
2106
|
+
isError: false
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
return { text: JSON.stringify({ ready: true, phase }), isError: false };
|
|
2110
|
+
} catch {
|
|
2111
|
+
return {
|
|
2112
|
+
text: JSON.stringify({ ready: false, phase, reason: "Answer file could not be parsed" }),
|
|
2113
|
+
isError: false
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
try {
|
|
2118
|
+
const answersDir = (0, import_path4.join)(cwd, ".stackwright", "answers");
|
|
2119
|
+
const answeredPhases = [];
|
|
2120
|
+
const missingPhases = [];
|
|
2121
|
+
for (const phase2 of PHASE_ORDER) {
|
|
2122
|
+
const answerFile = (0, import_path4.join)(answersDir, `${phase2}.json`);
|
|
2123
|
+
if ((0, import_fs4.existsSync)(answerFile)) {
|
|
2124
|
+
try {
|
|
2125
|
+
const raw = safeReadSync(answerFile);
|
|
2126
|
+
const parsed = JSON.parse(raw);
|
|
2127
|
+
if (typeof parsed["version"] !== "string" || typeof parsed["phase"] !== "string" || typeof parsed["answers"] !== "object" || parsed["answers"] === null) {
|
|
2128
|
+
missingPhases.push(phase2);
|
|
2129
|
+
continue;
|
|
2130
|
+
}
|
|
2131
|
+
answeredPhases.push(phase2);
|
|
2132
|
+
} catch {
|
|
2133
|
+
missingPhases.push(phase2);
|
|
2134
|
+
}
|
|
2135
|
+
} else {
|
|
2136
|
+
missingPhases.push(phase2);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
return {
|
|
2140
|
+
text: JSON.stringify({
|
|
2141
|
+
ready: missingPhases.length === 0,
|
|
2142
|
+
answeredPhases,
|
|
2143
|
+
missingPhases,
|
|
2144
|
+
totalPhases: PHASE_ORDER.length
|
|
2145
|
+
}),
|
|
2146
|
+
isError: false
|
|
2147
|
+
};
|
|
2148
|
+
} catch (err) {
|
|
2149
|
+
return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
function handleListArtifacts(_cwd) {
|
|
2153
|
+
const cwd = _cwd ?? process.cwd();
|
|
2154
|
+
try {
|
|
2155
|
+
const artifactsDir = (0, import_path4.join)(cwd, ".stackwright", "artifacts");
|
|
2156
|
+
const artifacts = [];
|
|
2157
|
+
let completedCount = 0;
|
|
2158
|
+
for (const phase of PHASE_ORDER) {
|
|
2159
|
+
const expectedFile = PHASE_ARTIFACT[phase];
|
|
2160
|
+
const fullPath = (0, import_path4.join)(artifactsDir, expectedFile);
|
|
2161
|
+
const exists = (0, import_fs4.existsSync)(fullPath);
|
|
2162
|
+
if (exists) completedCount++;
|
|
2163
|
+
artifacts.push({ phase, expectedFile, exists, path: fullPath });
|
|
2164
|
+
}
|
|
2165
|
+
return {
|
|
2166
|
+
text: JSON.stringify({ artifacts, completedCount, totalCount: PHASE_ORDER.length }),
|
|
2167
|
+
isError: false
|
|
2168
|
+
};
|
|
2169
|
+
} catch (err) {
|
|
2170
|
+
return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
function handleWritePhaseQuestions(input) {
|
|
2174
|
+
const cwd = input._cwd ?? process.cwd();
|
|
2175
|
+
const { phase, responseText } = input;
|
|
2176
|
+
if (!isValidPhase(phase)) {
|
|
2177
|
+
return {
|
|
2178
|
+
text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),
|
|
2179
|
+
isError: true
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
try {
|
|
2183
|
+
const questions = parseLLMQuestionsResponse(responseText);
|
|
2184
|
+
let requiredPackages = {
|
|
2185
|
+
dependencies: {},
|
|
2186
|
+
devPackages: {}
|
|
2187
|
+
};
|
|
2188
|
+
try {
|
|
2189
|
+
const fullParsed = extractJsonFromResponse(responseText);
|
|
2190
|
+
if (fullParsed.requiredPackages && typeof fullParsed.requiredPackages === "object") {
|
|
2191
|
+
const rp = fullParsed.requiredPackages;
|
|
2192
|
+
requiredPackages = {
|
|
2193
|
+
dependencies: rp.dependencies ?? {},
|
|
2194
|
+
devPackages: rp.devPackages ?? {}
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
} catch {
|
|
2198
|
+
}
|
|
2199
|
+
const questionsDir = (0, import_path4.join)(cwd, ".stackwright", "questions");
|
|
2200
|
+
(0, import_fs4.mkdirSync)(questionsDir, { recursive: true });
|
|
2201
|
+
const filePath = (0, import_path4.join)(questionsDir, `${phase}.json`);
|
|
2202
|
+
const payload = {
|
|
2203
|
+
version: "1.0",
|
|
2204
|
+
phase,
|
|
2205
|
+
otter: PHASE_TO_OTTER2[phase],
|
|
2206
|
+
collectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2207
|
+
questions,
|
|
2208
|
+
requiredPackages
|
|
2209
|
+
};
|
|
2210
|
+
safeWriteSync(filePath, JSON.stringify(payload, null, 2) + "\n");
|
|
2211
|
+
const state = readState(cwd);
|
|
2212
|
+
if (!state.phases[phase]) state.phases[phase] = defaultPhaseStatus();
|
|
2213
|
+
const ps = state.phases[phase];
|
|
2214
|
+
ps.questionsCollected = true;
|
|
2215
|
+
writeState(cwd, state);
|
|
2216
|
+
return {
|
|
2217
|
+
text: JSON.stringify({
|
|
2218
|
+
success: true,
|
|
2219
|
+
phase,
|
|
2220
|
+
questionCount: questions.length,
|
|
2221
|
+
requiredPackages,
|
|
2222
|
+
path: filePath
|
|
2223
|
+
}),
|
|
2224
|
+
isError: false
|
|
2225
|
+
};
|
|
2226
|
+
} catch (err) {
|
|
2227
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2228
|
+
return { text: JSON.stringify({ error: true, phase, message }), isError: true };
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
function handleBuildSpecialistPrompt(input) {
|
|
2232
|
+
const cwd = input._cwd ?? process.cwd();
|
|
2233
|
+
const { phase } = input;
|
|
2234
|
+
if (!isValidPhase(phase)) {
|
|
2235
|
+
return {
|
|
2236
|
+
text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),
|
|
2237
|
+
isError: true
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
try {
|
|
2241
|
+
const answersPath = (0, import_path4.join)(cwd, ".stackwright", "answers", `${phase}.json`);
|
|
2242
|
+
let answers = {};
|
|
2243
|
+
if ((0, import_fs4.existsSync)(answersPath)) {
|
|
2244
|
+
answers = JSON.parse(safeReadSync(answersPath));
|
|
2245
|
+
}
|
|
2246
|
+
let buildContextText = "";
|
|
2247
|
+
const buildContextPath = (0, import_path4.join)(cwd, ".stackwright", "build-context.json");
|
|
2248
|
+
if ((0, import_fs4.existsSync)(buildContextPath)) {
|
|
2249
|
+
try {
|
|
2250
|
+
const bcRaw = JSON.parse(safeReadSync(buildContextPath));
|
|
2251
|
+
if (typeof bcRaw.buildContext === "string" && bcRaw.buildContext.trim().length > 0) {
|
|
2252
|
+
buildContextText = bcRaw.buildContext.trim();
|
|
2253
|
+
}
|
|
2254
|
+
} catch {
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
const deps = PHASE_DEPENDENCIES[phase];
|
|
2258
|
+
const artifactSections = [];
|
|
2259
|
+
const missingDependencies = [];
|
|
2260
|
+
for (const dep of deps) {
|
|
2261
|
+
const artifactFile = PHASE_ARTIFACT[dep];
|
|
2262
|
+
const artifactPath = (0, import_path4.join)(cwd, ".stackwright", "artifacts", artifactFile);
|
|
2263
|
+
if ((0, import_fs4.existsSync)(artifactPath)) {
|
|
2264
|
+
const content = JSON.parse(safeReadSync(artifactPath));
|
|
2265
|
+
const expectedOtter = PHASE_TO_OTTER2[dep];
|
|
2266
|
+
const artifactOtter = content["generatedBy"];
|
|
2267
|
+
const normalizedOtter = artifactOtter?.replace(/-[a-f0-9]{6}$/, "");
|
|
2268
|
+
if (!artifactOtter) {
|
|
2269
|
+
missingDependencies.push(dep);
|
|
2270
|
+
artifactSections.push(
|
|
2271
|
+
`[${artifactFile}]:
|
|
2272
|
+
(integrity check failed: missing generatedBy field)`
|
|
2273
|
+
);
|
|
2274
|
+
} else if (normalizedOtter !== expectedOtter) {
|
|
2275
|
+
missingDependencies.push(dep);
|
|
2276
|
+
artifactSections.push(
|
|
2277
|
+
`[${artifactFile}]:
|
|
2278
|
+
(integrity check failed: artifact claims generatedBy="${artifactOtter}" but expected="${expectedOtter}")`
|
|
2279
|
+
);
|
|
2280
|
+
} else {
|
|
2281
|
+
artifactSections.push(`[${artifactFile}]:
|
|
2282
|
+
${JSON.stringify(content, null, 2)}`);
|
|
2283
|
+
}
|
|
2284
|
+
} else {
|
|
2285
|
+
missingDependencies.push(dep);
|
|
2286
|
+
artifactSections.push(`[${artifactFile}]:
|
|
2287
|
+
(not yet available)`);
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
const parts = [];
|
|
2291
|
+
if (buildContextText) {
|
|
2292
|
+
parts.push("BUILD_CONTEXT:", buildContextText, "");
|
|
2293
|
+
}
|
|
2294
|
+
parts.push("ANSWERS:", JSON.stringify(answers, null, 2));
|
|
2295
|
+
if (artifactSections.length > 0) {
|
|
2296
|
+
parts.push("", "UPSTREAM ARTIFACTS:", "", ...artifactSections);
|
|
2297
|
+
}
|
|
2298
|
+
parts.push("", "Execute using these answers and the upstream artifacts provided.");
|
|
2299
|
+
const prompt = parts.join("\n");
|
|
2300
|
+
const dependenciesSatisfied = missingDependencies.length === 0;
|
|
2301
|
+
return {
|
|
2302
|
+
text: JSON.stringify({
|
|
2303
|
+
otterName: PHASE_TO_OTTER2[phase],
|
|
2304
|
+
phase,
|
|
2305
|
+
prompt,
|
|
2306
|
+
dependenciesSatisfied,
|
|
2307
|
+
missingDependencies
|
|
2308
|
+
}),
|
|
2309
|
+
isError: false
|
|
2310
|
+
};
|
|
2311
|
+
} catch (err) {
|
|
2312
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2313
|
+
return { text: JSON.stringify({ error: true, phase, message }), isError: true };
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
var OFF_SCRIPT_PATTERNS = [
|
|
2317
|
+
{
|
|
2318
|
+
pattern: /```(?:ts|tsx|js|jsx|python|bash|sh|sql|ruby|go|rust|java|csharp|c\+\+)\b/,
|
|
2319
|
+
label: "code fence"
|
|
2320
|
+
},
|
|
2321
|
+
{ pattern: /\bimport\s+[\w{]/, label: "import statement" },
|
|
2322
|
+
{ pattern: /\bexport\s+(?:const|function|default|class)\b/, label: "export statement" },
|
|
2323
|
+
{ pattern: /\brequire\s*\(/, label: "require() call" },
|
|
2324
|
+
{ pattern: /\beval\s*\(/, label: "eval() call" },
|
|
2325
|
+
{ pattern: /^#!/m, label: "shebang" },
|
|
2326
|
+
{ pattern: /<script[\s>]/i, label: "script tag" },
|
|
2327
|
+
{ pattern: /\.(ts|tsx|js|jsx)\b.*\bfile\b/i, label: "code file reference" }
|
|
2328
|
+
];
|
|
2329
|
+
var PHASE_REQUIRED_KEYS = {
|
|
2330
|
+
designer: ["designLanguage", "themeTokenSeeds"],
|
|
2331
|
+
theme: ["tokens"],
|
|
2332
|
+
api: ["entities", "version", "generatedBy"],
|
|
2333
|
+
auth: ["version", "generatedBy"],
|
|
2334
|
+
data: ["version", "generatedBy", "strategy", "collections"],
|
|
2335
|
+
pages: ["version", "generatedBy"],
|
|
2336
|
+
dashboard: ["version", "generatedBy"],
|
|
2337
|
+
workflow: ["version", "generatedBy"]
|
|
2338
|
+
};
|
|
2339
|
+
function handleValidateArtifact(input) {
|
|
2340
|
+
const cwd = input._cwd ?? process.cwd();
|
|
2341
|
+
const { phase, responseText, artifact: directArtifact } = input;
|
|
2342
|
+
if (!isValidPhase(phase)) {
|
|
2343
|
+
return {
|
|
2344
|
+
text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),
|
|
2345
|
+
isError: true
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
let artifact;
|
|
2349
|
+
if (directArtifact) {
|
|
2350
|
+
artifact = directArtifact;
|
|
2351
|
+
} else {
|
|
2352
|
+
const text = responseText ?? "";
|
|
2353
|
+
for (const { pattern, label } of OFF_SCRIPT_PATTERNS) {
|
|
2354
|
+
if (pattern.test(text)) {
|
|
2355
|
+
const result = {
|
|
2356
|
+
valid: false,
|
|
2357
|
+
phase,
|
|
2358
|
+
violation: "off-script",
|
|
2359
|
+
retryPrompt: `You returned code output (detected: ${label}). Return ONLY a JSON artifact \u2014 no code, no files.`
|
|
2360
|
+
};
|
|
2361
|
+
return { text: JSON.stringify(result), isError: false };
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
try {
|
|
2365
|
+
artifact = extractJsonFromResponse(text);
|
|
2366
|
+
} catch {
|
|
2367
|
+
const result = {
|
|
2368
|
+
valid: false,
|
|
2369
|
+
phase,
|
|
2370
|
+
violation: "invalid-json",
|
|
2371
|
+
retryPrompt: "Your response did not contain valid JSON. Return a single JSON object with no surrounding text."
|
|
2372
|
+
};
|
|
2373
|
+
return { text: JSON.stringify(result), isError: false };
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
if (!artifact.version || !artifact.generatedBy) {
|
|
2377
|
+
const result = {
|
|
2378
|
+
valid: false,
|
|
2379
|
+
phase,
|
|
2380
|
+
violation: "missing-fields",
|
|
2381
|
+
retryPrompt: 'Your JSON artifact is missing required fields. Every artifact MUST include "version" and "generatedBy" at the top level.'
|
|
2382
|
+
};
|
|
2383
|
+
return { text: JSON.stringify(result), isError: false };
|
|
2384
|
+
}
|
|
2385
|
+
const requiredKeys = PHASE_REQUIRED_KEYS[phase];
|
|
2386
|
+
const missingKeys = requiredKeys.filter((k) => !(k in artifact));
|
|
2387
|
+
if (missingKeys.length > 0) {
|
|
2388
|
+
const result = {
|
|
2389
|
+
valid: false,
|
|
2390
|
+
phase,
|
|
2391
|
+
violation: "schema-mismatch",
|
|
2392
|
+
retryPrompt: `Your ${phase} artifact is missing required keys: ${missingKeys.join(", ")}. Include them and try again.`
|
|
2393
|
+
};
|
|
2394
|
+
return { text: JSON.stringify(result), isError: false };
|
|
2395
|
+
}
|
|
2396
|
+
const PHASE_ZOD_VALIDATORS = {
|
|
2397
|
+
workflow: (artifact2) => {
|
|
2398
|
+
const workflowConfig = artifact2["workflowConfig"];
|
|
2399
|
+
if (!workflowConfig) return { success: true };
|
|
2400
|
+
const result = import_types.WorkflowFileSchema.safeParse(workflowConfig);
|
|
2401
|
+
if (!result.success) {
|
|
2402
|
+
const issues = result.error.issues.slice(0, 3).map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
2403
|
+
return { success: false, error: { message: issues } };
|
|
2404
|
+
}
|
|
2405
|
+
return { success: true };
|
|
2406
|
+
},
|
|
2407
|
+
auth: (artifact2) => {
|
|
2408
|
+
const authConfig = artifact2["authConfig"];
|
|
2409
|
+
if (!authConfig) return { success: true };
|
|
2410
|
+
const result = import_types.authConfigSchema.safeParse(authConfig);
|
|
2411
|
+
if (!result.success) {
|
|
2412
|
+
const issues = result.error.issues.slice(0, 3).map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
2413
|
+
return { success: false, error: { message: issues } };
|
|
2414
|
+
}
|
|
2415
|
+
return { success: true };
|
|
2416
|
+
}
|
|
2417
|
+
};
|
|
2418
|
+
const zodValidator = PHASE_ZOD_VALIDATORS[phase];
|
|
2419
|
+
if (zodValidator) {
|
|
2420
|
+
const zodResult = zodValidator(artifact);
|
|
2421
|
+
if (!zodResult.success) {
|
|
2422
|
+
const result = {
|
|
2423
|
+
valid: false,
|
|
2424
|
+
phase,
|
|
2425
|
+
violation: "schema-mismatch",
|
|
2426
|
+
retryPrompt: `Your artifact failed schema validation: ${zodResult.error?.message}. Fix these fields and return the corrected JSON artifact.`
|
|
2427
|
+
};
|
|
2428
|
+
return { text: JSON.stringify(result), isError: false };
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
try {
|
|
2432
|
+
const artifactsDir = (0, import_path4.join)(cwd, ".stackwright", "artifacts");
|
|
2433
|
+
(0, import_fs4.mkdirSync)(artifactsDir, { recursive: true });
|
|
2434
|
+
const artifactFile = PHASE_ARTIFACT[phase];
|
|
2435
|
+
const artifactPath = (0, import_path4.join)(artifactsDir, artifactFile);
|
|
2436
|
+
safeWriteSync(artifactPath, JSON.stringify(artifact, null, 2) + "\n");
|
|
2437
|
+
const state = readState(cwd);
|
|
2438
|
+
if (!state.phases[phase]) state.phases[phase] = defaultPhaseStatus();
|
|
2439
|
+
const ps = state.phases[phase];
|
|
2440
|
+
ps.artifactWritten = true;
|
|
2441
|
+
writeState(cwd, state);
|
|
2442
|
+
const topKeys = Object.keys(artifact).slice(0, 5).join(", ");
|
|
2443
|
+
const result = {
|
|
2444
|
+
valid: true,
|
|
2445
|
+
phase,
|
|
2446
|
+
artifactPath,
|
|
2447
|
+
summary: `Wrote ${artifactFile} (keys: ${topKeys}${Object.keys(artifact).length > 5 ? ", ..." : ""})`
|
|
2448
|
+
};
|
|
2449
|
+
return { text: JSON.stringify(result), isError: false };
|
|
2450
|
+
} catch (err) {
|
|
2451
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2452
|
+
return { text: JSON.stringify({ error: true, phase, message }), isError: true };
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
function registerPipelineTools(server2) {
|
|
2456
|
+
const DESC = "Writes state to .stackwright/ \u2014 the filesystem is the state machine.";
|
|
2457
|
+
const res = (r) => ({
|
|
2458
|
+
content: [{ type: "text", text: r.text }],
|
|
2459
|
+
isError: r.isError
|
|
2460
|
+
});
|
|
2461
|
+
server2.tool(
|
|
2462
|
+
"stackwright_pro_get_pipeline_state",
|
|
2463
|
+
`Read pipeline state from .stackwright/pipeline-state.json. ${DESC}`,
|
|
2464
|
+
{},
|
|
2465
|
+
async () => res(handleGetPipelineState())
|
|
2466
|
+
);
|
|
2467
|
+
server2.tool(
|
|
2468
|
+
"stackwright_pro_set_pipeline_state",
|
|
2469
|
+
`Atomic read\u2192modify\u2192write pipeline state. ${DESC}`,
|
|
2470
|
+
{
|
|
2471
|
+
phase: import_zod10.z.string().optional().describe('Phase to update, e.g. "designer"'),
|
|
2472
|
+
field: import_zod10.z.enum(["questionsCollected", "answered", "executed", "artifactWritten"]).optional().describe("Boolean field to set"),
|
|
2473
|
+
value: boolCoerce(import_zod10.z.boolean().optional()).describe(
|
|
2474
|
+
'Value for the field \u2014 must be a JSON boolean (true/false), NOT the string "true"/"false"'
|
|
2475
|
+
),
|
|
2476
|
+
status: import_zod10.z.enum(["setup", "questions", "execution", "done"]).optional().describe("Top-level status override"),
|
|
2477
|
+
incrementRetry: boolCoerce(import_zod10.z.boolean().optional()).describe(
|
|
2478
|
+
"Bump retryCount by 1 \u2014 must be a JSON boolean"
|
|
2479
|
+
)
|
|
2480
|
+
},
|
|
2481
|
+
async (args) => res(
|
|
2482
|
+
handleSetPipelineState({
|
|
2483
|
+
...args.phase != null ? { phase: args.phase } : {},
|
|
2484
|
+
...args.field != null ? { field: args.field } : {},
|
|
2485
|
+
...args.value != null ? { value: args.value } : {},
|
|
2486
|
+
...args.status != null ? { status: args.status } : {},
|
|
2487
|
+
...args.incrementRetry != null ? { incrementRetry: args.incrementRetry } : {}
|
|
2488
|
+
})
|
|
2489
|
+
)
|
|
2490
|
+
);
|
|
2491
|
+
server2.tool(
|
|
2492
|
+
"stackwright_pro_check_execution_ready",
|
|
2493
|
+
`Check all phases have answer files in .stackwright/answers/. If phase is provided, check only that phase. ${DESC}`,
|
|
2494
|
+
{
|
|
2495
|
+
phase: import_zod10.z.string().optional().describe("If provided, check only this phase's readiness. Omit to check all phases.")
|
|
2496
|
+
},
|
|
2497
|
+
async ({ phase }) => res(handleCheckExecutionReady(void 0, phase))
|
|
2498
|
+
);
|
|
2499
|
+
server2.tool(
|
|
2500
|
+
"stackwright_pro_list_artifacts",
|
|
2501
|
+
`List phase artifacts in .stackwright/artifacts/ with completedCount/totalCount. ${DESC}`,
|
|
2502
|
+
{},
|
|
2503
|
+
async () => res(handleListArtifacts())
|
|
2504
|
+
);
|
|
2505
|
+
server2.tool(
|
|
2506
|
+
"stackwright_pro_write_phase_questions",
|
|
2507
|
+
`Parse otter question-collection response \u2192 .stackwright/questions/{phase}.json. Specialists may also call this directly with a parsed questions array. ${DESC}`,
|
|
2508
|
+
{
|
|
2509
|
+
phase: import_zod10.z.string().optional().describe('Phase name, e.g. "designer" (required for direct write)'),
|
|
2510
|
+
responseText: import_zod10.z.string().optional().describe("Raw LLM response from QUESTION_COLLECTION_MODE"),
|
|
2511
|
+
questions: jsonCoerce(import_zod10.z.array(import_zod10.z.any()).optional()).describe(
|
|
2512
|
+
"Questions array for direct specialist write"
|
|
2513
|
+
)
|
|
2514
|
+
},
|
|
2515
|
+
async ({ phase, responseText, questions }) => {
|
|
2516
|
+
if (phase && questions && Array.isArray(questions)) {
|
|
2517
|
+
const SAFE_PHASE = /^[a-z][a-z0-9-]{0,30}$/;
|
|
2518
|
+
if (!SAFE_PHASE.test(phase)) {
|
|
2519
|
+
return {
|
|
2520
|
+
content: [
|
|
2521
|
+
{ type: "text", text: JSON.stringify({ error: `Invalid phase name` }) }
|
|
2522
|
+
],
|
|
2523
|
+
isError: true
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
const questionsDir = (0, import_path4.join)(process.cwd(), ".stackwright", "questions");
|
|
2527
|
+
(0, import_fs4.mkdirSync)(questionsDir, { recursive: true });
|
|
2528
|
+
const outPath = (0, import_path4.join)(questionsDir, `${phase}.json`);
|
|
2529
|
+
(0, import_fs4.writeFileSync)(
|
|
2530
|
+
outPath,
|
|
2531
|
+
JSON.stringify({ phase, questions, writtenAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)
|
|
2532
|
+
);
|
|
2533
|
+
return {
|
|
2534
|
+
content: [
|
|
2535
|
+
{
|
|
2536
|
+
type: "text",
|
|
2537
|
+
text: JSON.stringify({
|
|
2538
|
+
written: true,
|
|
2539
|
+
phase,
|
|
2540
|
+
count: questions.length
|
|
2541
|
+
})
|
|
2542
|
+
}
|
|
2543
|
+
]
|
|
2544
|
+
};
|
|
2545
|
+
}
|
|
2546
|
+
return res(
|
|
2547
|
+
handleWritePhaseQuestions({ phase: phase ?? "", responseText: responseText ?? "" })
|
|
2548
|
+
);
|
|
2549
|
+
}
|
|
2550
|
+
);
|
|
2551
|
+
server2.tool(
|
|
2552
|
+
"stackwright_pro_build_specialist_prompt",
|
|
2553
|
+
`Assemble execution prompt from answers + upstream artifacts. Foreman passes verbatim. ${DESC}`,
|
|
2554
|
+
{ phase: import_zod10.z.string().describe('Phase to build prompt for, e.g. "pages"') },
|
|
2555
|
+
async ({ phase }) => res(handleBuildSpecialistPrompt({ phase }))
|
|
2556
|
+
);
|
|
2557
|
+
server2.tool(
|
|
2558
|
+
"stackwright_pro_validate_artifact",
|
|
2559
|
+
`Validate and write artifact to .stackwright/artifacts/. Returns retryPrompt on failure. ${DESC}`,
|
|
2560
|
+
{
|
|
2561
|
+
phase: import_zod10.z.string().describe('Phase that produced this artifact, e.g. "designer"'),
|
|
2562
|
+
responseText: import_zod10.z.string().optional().describe("Raw response text from the specialist otter (Foreman-mediated path, legacy)"),
|
|
2563
|
+
artifact: import_zod10.z.record(import_zod10.z.unknown()).optional().describe(
|
|
2564
|
+
"Artifact object to validate and write directly (specialist direct path \u2014 skips off-script detection and JSON parsing)"
|
|
2565
|
+
)
|
|
2566
|
+
},
|
|
2567
|
+
async ({ phase, responseText, artifact }) => {
|
|
2568
|
+
if (artifact) {
|
|
2569
|
+
return res(
|
|
2570
|
+
handleValidateArtifact({ phase, artifact })
|
|
2571
|
+
);
|
|
2572
|
+
}
|
|
2573
|
+
return res(handleValidateArtifact({ phase, responseText: responseText ?? "" }));
|
|
2574
|
+
}
|
|
2575
|
+
);
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
// src/tools/safe-write.ts
|
|
2579
|
+
var import_zod11 = require("zod");
|
|
2580
|
+
var import_fs5 = require("fs");
|
|
2581
|
+
var import_path5 = require("path");
|
|
2582
|
+
var OTTER_WRITE_ALLOWLISTS = {
|
|
2583
|
+
"stackwright-pro-designer-otter": [
|
|
2584
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Design language artifact" }
|
|
2585
|
+
],
|
|
2586
|
+
"stackwright-pro-theme-otter": [
|
|
2587
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Theme tokens artifact" }
|
|
2588
|
+
],
|
|
2589
|
+
"stackwright-pro-auth-otter": [
|
|
2590
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Auth config artifact" },
|
|
2591
|
+
{ prefix: "config/", suffix: ".yml", description: "Auth YAML config" },
|
|
2592
|
+
{ prefix: "config/", suffix: ".yaml", description: "Auth YAML config" },
|
|
2593
|
+
{
|
|
2594
|
+
prefix: ".env",
|
|
2595
|
+
suffix: "",
|
|
2596
|
+
description: "Dotenv files (.env, .env.local, .env.production, etc.)"
|
|
2597
|
+
}
|
|
2598
|
+
],
|
|
2599
|
+
"stackwright-pro-data-otter": [
|
|
2600
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Data config artifact" },
|
|
2601
|
+
{ prefix: "stackwright.yml", suffix: "", description: "Stackwright config" }
|
|
2602
|
+
],
|
|
2603
|
+
"stackwright-pro-page-otter": [
|
|
2604
|
+
{ prefix: "pages/", suffix: "/content.yml", description: "Page content YAML" },
|
|
2605
|
+
{ prefix: "pages/", suffix: "/content.yaml", description: "Page content YAML" },
|
|
2606
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Pages manifest" }
|
|
2607
|
+
],
|
|
2608
|
+
"stackwright-pro-dashboard-otter": [
|
|
2609
|
+
{ prefix: "pages/", suffix: "/content.yml", description: "Dashboard content YAML" },
|
|
2610
|
+
{ prefix: "pages/", suffix: "/content.yaml", description: "Dashboard content YAML" },
|
|
2611
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Dashboard manifest" }
|
|
2612
|
+
],
|
|
2613
|
+
"stackwright-pro-workflow-otter": [
|
|
2614
|
+
{ prefix: "workflows/", suffix: ".yml", description: "Workflow definition" },
|
|
2615
|
+
{ prefix: "workflows/", suffix: ".yaml", description: "Workflow definition" },
|
|
2616
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Workflow config" }
|
|
2617
|
+
],
|
|
2618
|
+
"stackwright-pro-api-otter": [
|
|
2619
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "API config artifact" }
|
|
2620
|
+
]
|
|
2621
|
+
};
|
|
2622
|
+
var PROTECTED_PATH_PREFIXES = [
|
|
2623
|
+
".stackwright/pipeline-state.json",
|
|
2624
|
+
".stackwright/questions/",
|
|
2625
|
+
".stackwright/answers/"
|
|
2626
|
+
];
|
|
2627
|
+
function checkPathAllowed(callerOtter, filePath) {
|
|
2628
|
+
const normalized = (0, import_path5.normalize)(filePath);
|
|
2629
|
+
if (normalized.includes("..")) {
|
|
2630
|
+
return { allowed: false, error: 'Path traversal detected: ".." segments are not allowed' };
|
|
2631
|
+
}
|
|
2632
|
+
if ((0, import_path5.isAbsolute)(normalized)) {
|
|
2633
|
+
return {
|
|
2634
|
+
allowed: false,
|
|
2635
|
+
error: "Absolute paths are not allowed \u2014 use paths relative to project root"
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
if (callerOtter === "stackwright-pro-foreman-otter") {
|
|
2639
|
+
return {
|
|
2640
|
+
allowed: false,
|
|
2641
|
+
error: "The foreman otter coordinates \u2014 it does not write files directly"
|
|
2642
|
+
};
|
|
2643
|
+
}
|
|
2644
|
+
const allowlist = OTTER_WRITE_ALLOWLISTS[callerOtter];
|
|
2645
|
+
if (!allowlist) {
|
|
2646
|
+
return {
|
|
2647
|
+
allowed: false,
|
|
2648
|
+
error: `Unknown otter: "${callerOtter}" is not in the write allowlist`
|
|
2649
|
+
};
|
|
2650
|
+
}
|
|
2651
|
+
for (const protectedPrefix of PROTECTED_PATH_PREFIXES) {
|
|
2652
|
+
if (normalized === protectedPrefix || normalized.startsWith(protectedPrefix)) {
|
|
2653
|
+
return {
|
|
2654
|
+
allowed: false,
|
|
2655
|
+
error: `Path "${normalized}" is managed by dedicated sink tools, not safe_write`
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
for (const rule of allowlist) {
|
|
2660
|
+
const prefixMatch = normalized.startsWith(rule.prefix);
|
|
2661
|
+
const suffixMatch = rule.suffix === "" || normalized.endsWith(rule.suffix);
|
|
2662
|
+
if (prefixMatch && suffixMatch) {
|
|
2663
|
+
if (rule.prefix === ".env" && rule.suffix === "") {
|
|
2664
|
+
if (!/^\.env(\.[a-zA-Z0-9]{3,})*$/.test(normalized)) {
|
|
2665
|
+
continue;
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
return { allowed: true, rule: rule.description };
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
return {
|
|
2672
|
+
allowed: false,
|
|
2673
|
+
error: `Path "${normalized}" does not match any allowed write pattern for ${callerOtter}`
|
|
2674
|
+
};
|
|
2675
|
+
}
|
|
2676
|
+
function validateJsonContent(content) {
|
|
2677
|
+
try {
|
|
2678
|
+
JSON.parse(content);
|
|
2679
|
+
return null;
|
|
2680
|
+
} catch (err) {
|
|
2681
|
+
return `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`;
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
function validateYamlContent(content) {
|
|
2685
|
+
const trimmed = content.trimStart();
|
|
2686
|
+
const anchorRefPattern = /\[(\*[\w]+)\]/g;
|
|
2687
|
+
const matches = [...content.matchAll(anchorRefPattern)];
|
|
2688
|
+
if (matches.length >= 20) {
|
|
2689
|
+
return "YAML entity expansion pattern detected \u2014 too many anchor references";
|
|
2690
|
+
}
|
|
2691
|
+
const anchorDefPattern = /&([\w]+)/g;
|
|
2692
|
+
const defs = [...content.matchAll(anchorDefPattern)];
|
|
2693
|
+
if (defs.length >= 10) {
|
|
2694
|
+
const anchorNameCounts = /* @__PURE__ */ new Map();
|
|
2695
|
+
for (const [, name] of defs) {
|
|
2696
|
+
const count = (anchorNameCounts.get(name) ?? 0) + 1;
|
|
2697
|
+
anchorNameCounts.set(name, count);
|
|
2698
|
+
if (count >= 10) {
|
|
2699
|
+
return "YAML entity expansion: repeated anchor definitions detected";
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
if (trimmed.startsWith("import ") || trimmed.startsWith("import{")) {
|
|
2704
|
+
return 'Content starts with "import" \u2014 this looks like code, not YAML';
|
|
2705
|
+
}
|
|
2706
|
+
if (trimmed.startsWith("export ") || trimmed.startsWith("export{")) {
|
|
2707
|
+
return 'Content starts with "export" \u2014 this looks like code, not YAML';
|
|
2708
|
+
}
|
|
2709
|
+
if (trimmed.startsWith("#!"))
|
|
2710
|
+
return "Content starts with shebang \u2014 this looks like a script, not YAML";
|
|
2711
|
+
if (/!!(?:python|ruby|perl|js|java)/i.test(content))
|
|
2712
|
+
return "YAML deserialization attack tags detected";
|
|
2713
|
+
if (trimmed.startsWith("<") && !trimmed.startsWith("<<"))
|
|
2714
|
+
return "Content looks like markup, not YAML";
|
|
2715
|
+
return null;
|
|
2716
|
+
}
|
|
2717
|
+
function validateEnvContent(content) {
|
|
2718
|
+
const lines = content.split("\n");
|
|
2719
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2720
|
+
const raw = lines[i];
|
|
2721
|
+
if (raw === void 0) continue;
|
|
2722
|
+
const line = raw.trim();
|
|
2723
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
2724
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*=/.test(line)) {
|
|
2725
|
+
return `Line ${i + 1} is not a valid env entry (expected KEY=value): "${line}"`;
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
return null;
|
|
2729
|
+
}
|
|
2730
|
+
function validateContent(filePath, content) {
|
|
2731
|
+
if (filePath.endsWith(".json")) return validateJsonContent(content);
|
|
2732
|
+
if (filePath.endsWith(".yml") || filePath.endsWith(".yaml")) return validateYamlContent(content);
|
|
2733
|
+
if (filePath === ".env" || filePath.startsWith(".env")) return validateEnvContent(content);
|
|
2734
|
+
return null;
|
|
2735
|
+
}
|
|
2736
|
+
function handleSafeWrite(input) {
|
|
2737
|
+
const cwd = input._cwd ?? process.cwd();
|
|
2738
|
+
const { callerOtter, filePath, content, createDirectories = true } = input;
|
|
2739
|
+
const check = checkPathAllowed(callerOtter, filePath);
|
|
2740
|
+
if (!check.allowed) {
|
|
2741
|
+
const allowlist = OTTER_WRITE_ALLOWLISTS[callerOtter] ?? [];
|
|
2742
|
+
const allowedPaths = allowlist.map((r) => `${r.prefix}*${r.suffix} (${r.description})`);
|
|
2743
|
+
const result = {
|
|
2744
|
+
success: false,
|
|
2745
|
+
error: check.error ?? "Path not allowed",
|
|
2746
|
+
callerOtter,
|
|
2747
|
+
attemptedPath: filePath,
|
|
2748
|
+
allowedPaths
|
|
2749
|
+
};
|
|
2750
|
+
return { text: JSON.stringify(result), isError: true };
|
|
2751
|
+
}
|
|
2752
|
+
const normalized = (0, import_path5.normalize)(filePath);
|
|
2753
|
+
const fullPath = (0, import_path5.join)(cwd, normalized);
|
|
2754
|
+
if ((0, import_fs5.existsSync)(fullPath)) {
|
|
2755
|
+
try {
|
|
2756
|
+
const stat = (0, import_fs5.lstatSync)(fullPath);
|
|
2757
|
+
if (stat.isSymbolicLink()) {
|
|
2758
|
+
const result = {
|
|
2759
|
+
success: false,
|
|
2760
|
+
error: "Target path is a symlink \u2014 refusing to write through symlinks for security",
|
|
2761
|
+
callerOtter,
|
|
2762
|
+
attemptedPath: filePath,
|
|
2763
|
+
allowedPaths: []
|
|
2764
|
+
};
|
|
2765
|
+
return { text: JSON.stringify(result), isError: true };
|
|
2766
|
+
}
|
|
2767
|
+
} catch {
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
const contentError = validateContent(normalized, content);
|
|
2771
|
+
if (contentError) {
|
|
2772
|
+
const result = {
|
|
2773
|
+
success: false,
|
|
2774
|
+
error: `Content validation failed: ${contentError}`,
|
|
2775
|
+
callerOtter,
|
|
2776
|
+
attemptedPath: filePath,
|
|
2777
|
+
allowedPaths: []
|
|
2778
|
+
};
|
|
2779
|
+
return { text: JSON.stringify(result), isError: true };
|
|
2780
|
+
}
|
|
2781
|
+
try {
|
|
2782
|
+
if (createDirectories) {
|
|
2783
|
+
(0, import_fs5.mkdirSync)((0, import_path5.dirname)(fullPath), { recursive: true });
|
|
2784
|
+
}
|
|
2785
|
+
(0, import_fs5.writeFileSync)(fullPath, content, { encoding: "utf-8" });
|
|
2786
|
+
const result = {
|
|
2787
|
+
success: true,
|
|
2788
|
+
path: normalized,
|
|
2789
|
+
bytesWritten: Buffer.byteLength(content, "utf-8"),
|
|
2790
|
+
allowRule: check.rule ?? "unknown"
|
|
2791
|
+
};
|
|
2792
|
+
return { text: JSON.stringify(result), isError: false };
|
|
2793
|
+
} catch (err) {
|
|
2794
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2795
|
+
const result = {
|
|
2796
|
+
success: false,
|
|
2797
|
+
error: `Write failed: ${message}`,
|
|
2798
|
+
callerOtter,
|
|
2799
|
+
attemptedPath: filePath,
|
|
2800
|
+
allowedPaths: []
|
|
2801
|
+
};
|
|
2802
|
+
return { text: JSON.stringify(result), isError: true };
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
function registerSafeWriteTools(server2) {
|
|
2806
|
+
const DESC = "Controlled file-write chokepoint. Every write from specialist otters goes through this tool with per-otter path allowlists. The LLM cannot write to arbitrary filesystem paths.";
|
|
2807
|
+
server2.tool(
|
|
2808
|
+
"stackwright_pro_safe_write",
|
|
2809
|
+
DESC,
|
|
2810
|
+
{
|
|
2811
|
+
callerOtter: import_zod11.z.string().describe('The otter agent name requesting the write, e.g. "stackwright-pro-page-otter"'),
|
|
2812
|
+
filePath: import_zod11.z.string().describe('Relative path from project root, e.g. "pages/dashboard/content.yml"'),
|
|
2813
|
+
content: import_zod11.z.string().describe("File content to write"),
|
|
2814
|
+
createDirectories: boolCoerce(import_zod11.z.boolean().optional().default(true)).describe(
|
|
2815
|
+
"Create parent directories if they don't exist. Default: true"
|
|
2816
|
+
)
|
|
2817
|
+
},
|
|
2818
|
+
async ({ callerOtter, filePath, content, createDirectories }) => {
|
|
2819
|
+
const result = handleSafeWrite({
|
|
2820
|
+
callerOtter,
|
|
2821
|
+
filePath,
|
|
2822
|
+
content,
|
|
2823
|
+
...createDirectories != null ? { createDirectories } : {}
|
|
2824
|
+
});
|
|
2825
|
+
return { content: [{ type: "text", text: result.text }], isError: result.isError };
|
|
2826
|
+
}
|
|
2827
|
+
);
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
// src/tools/auth.ts
|
|
2831
|
+
var import_zod12 = require("zod");
|
|
2832
|
+
var import_fs6 = require("fs");
|
|
2833
|
+
var import_path6 = require("path");
|
|
2834
|
+
function buildHierarchy(roles) {
|
|
2835
|
+
const h = {};
|
|
2836
|
+
for (let i = 0; i < roles.length - 1; i++) {
|
|
2837
|
+
h[roles[i]] = roles.slice(i + 1);
|
|
2838
|
+
}
|
|
2839
|
+
return h;
|
|
2840
|
+
}
|
|
2841
|
+
function hierarchyToYaml(hierarchy, indent) {
|
|
2842
|
+
const entries = Object.entries(hierarchy);
|
|
2843
|
+
if (entries.length === 0) return `${indent}{}`;
|
|
2844
|
+
return entries.map(([role, subs]) => `${indent}${role}: [${subs.join(", ")}]`).join("\n");
|
|
2845
|
+
}
|
|
2846
|
+
function rolesToYaml(roles, indent) {
|
|
2847
|
+
return roles.map((r) => `${indent}- ${r}`).join("\n");
|
|
2848
|
+
}
|
|
2849
|
+
function routesToYaml(routes, defaultRole, indent) {
|
|
2850
|
+
return routes.map((r) => `${indent}- pattern: ${r}
|
|
2851
|
+
${indent} requiredRole: ${defaultRole}`).join("\n");
|
|
2852
|
+
}
|
|
2853
|
+
function upsertAuthBlock(existing, authYaml) {
|
|
2854
|
+
const lines = existing.split("\n");
|
|
2855
|
+
let authStart = -1;
|
|
2856
|
+
let authEnd = lines.length;
|
|
2857
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2858
|
+
if (/^auth:/.test(lines[i])) {
|
|
2859
|
+
authStart = i;
|
|
2860
|
+
} else if (authStart >= 0 && i > authStart && /^\S/.test(lines[i]) && lines[i].trim() !== "") {
|
|
2861
|
+
authEnd = i;
|
|
2862
|
+
break;
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
if (authStart < 0) {
|
|
2866
|
+
return existing.trimEnd() + "\n" + authYaml + "\n";
|
|
2867
|
+
}
|
|
2868
|
+
const before = lines.slice(0, authStart);
|
|
2869
|
+
const after = lines.slice(authEnd);
|
|
2870
|
+
return [...before, ...authYaml.trimEnd().split("\n"), ...after.length ? after : []].join("\n");
|
|
2871
|
+
}
|
|
2872
|
+
function generateMiddlewareContent(method, params, roles, defaultRole, hierarchy, auditEnabled, auditRetentionDays, protectedRoutes) {
|
|
2873
|
+
const rbacBlock = ` rbac: {
|
|
2874
|
+
roles: ${JSON.stringify(roles)},
|
|
2875
|
+
defaultRole: '${defaultRole}',
|
|
2876
|
+
hierarchy: ${JSON.stringify(hierarchy, null, 4)},
|
|
2877
|
+
},`;
|
|
2878
|
+
const auditBlock = ` audit: {
|
|
2879
|
+
enabled: ${auditEnabled},
|
|
2880
|
+
retentionDays: ${auditRetentionDays},
|
|
2881
|
+
},`;
|
|
2882
|
+
const routesBlock = ` protectedRoutes: ${JSON.stringify(protectedRoutes)},`;
|
|
2883
|
+
const configBlock = `export const config = {
|
|
2884
|
+
matcher: ${JSON.stringify(protectedRoutes)},
|
|
2885
|
+
};`;
|
|
2886
|
+
if (method === "cac") {
|
|
2887
|
+
const caBundle = params.cacCaBundle ?? "./certs/dod-ca-bundle.pem";
|
|
2888
|
+
const edipiLookup = params.cacEdipiLookup ?? "./config/edipi-lookup.json";
|
|
2889
|
+
const ocspEndpoint = params.cacOcspEndpoint ?? "https://ocsp.disa.mil";
|
|
2890
|
+
const certHeader = params.cacCertHeader ?? "X-SSL-Client-Cert";
|
|
2891
|
+
return `// middleware.ts \u2014 generated by @stackwright-pro/auth
|
|
2892
|
+
// \u26A0\uFE0F SECURITY REVIEW REQUIRED \u2014 CAC/PKI certificate validation
|
|
2893
|
+
// DoD security officer review required before production deployment.
|
|
2894
|
+
// Verify: CA bundle completeness, EDIPI lookup endpoint, OCSP accessibility.
|
|
2895
|
+
import { createProMiddleware } from '@stackwright-pro/auth-nextjs';
|
|
2896
|
+
|
|
2897
|
+
export const middleware = createProMiddleware({
|
|
2898
|
+
method: 'cac',
|
|
2899
|
+
cac: {
|
|
2900
|
+
caBundle: process.env.CAC_CA_BUNDLE ?? '${caBundle}',
|
|
2901
|
+
edipiLookup: '${edipiLookup}',
|
|
2902
|
+
ocspEndpoint: process.env.CAC_OCSP_ENDPOINT ?? '${ocspEndpoint}',
|
|
2903
|
+
certHeader: '${certHeader}',
|
|
2904
|
+
},
|
|
2905
|
+
${rbacBlock}
|
|
2906
|
+
${auditBlock}
|
|
2907
|
+
${routesBlock}
|
|
2908
|
+
});
|
|
2909
|
+
|
|
2910
|
+
${configBlock}
|
|
2911
|
+
`;
|
|
2912
|
+
}
|
|
2913
|
+
if (method === "oidc") {
|
|
2914
|
+
const scopes2 = params.oidcScopes ?? "openid profile email";
|
|
2915
|
+
const roleClaim = params.oidcRoleClaim ?? "roles";
|
|
2916
|
+
return `// middleware.ts \u2014 generated by @stackwright-pro/auth-nextjs
|
|
2917
|
+
import { createProMiddleware } from '@stackwright-pro/auth-nextjs';
|
|
2918
|
+
|
|
2919
|
+
export const middleware = createProMiddleware({
|
|
2920
|
+
method: 'oidc',
|
|
2921
|
+
oidc: {
|
|
2922
|
+
discoveryUrl: process.env.OIDC_DISCOVERY_URL!,
|
|
2923
|
+
clientId: process.env.OIDC_CLIENT_ID!,
|
|
2924
|
+
clientSecret: process.env.OIDC_CLIENT_SECRET!,
|
|
2925
|
+
scopes: '${scopes2}',
|
|
2926
|
+
roleClaim: '${roleClaim}',
|
|
2927
|
+
},
|
|
2928
|
+
${rbacBlock}
|
|
2929
|
+
${auditBlock}
|
|
2930
|
+
${routesBlock}
|
|
2931
|
+
});
|
|
2932
|
+
|
|
2933
|
+
${configBlock}
|
|
2934
|
+
`;
|
|
2935
|
+
}
|
|
2936
|
+
const scopes = params.oauth2Scopes ?? "read write";
|
|
2937
|
+
return `// middleware.ts \u2014 generated by @stackwright-pro/auth-nextjs
|
|
2938
|
+
import { createProMiddleware } from '@stackwright-pro/auth-nextjs';
|
|
2939
|
+
|
|
2940
|
+
export const middleware = createProMiddleware({
|
|
2941
|
+
method: 'oauth2',
|
|
2942
|
+
oauth2: {
|
|
2943
|
+
authorizationUrl: process.env.OAUTH2_AUTH_URL!,
|
|
2944
|
+
tokenUrl: process.env.OAUTH2_TOKEN_URL!,
|
|
2945
|
+
clientId: process.env.OAUTH2_CLIENT_ID!,
|
|
2946
|
+
clientSecret: process.env.OAUTH2_CLIENT_SECRET!,
|
|
2947
|
+
scopes: '${scopes}',
|
|
2948
|
+
},
|
|
2949
|
+
${rbacBlock}
|
|
2950
|
+
${auditBlock}
|
|
2951
|
+
${routesBlock}
|
|
2952
|
+
});
|
|
2953
|
+
|
|
2954
|
+
${configBlock}
|
|
2955
|
+
`;
|
|
2956
|
+
}
|
|
2957
|
+
function generateEnvBlock(method, params) {
|
|
2958
|
+
if (method === "cac") {
|
|
2959
|
+
return `# Authentication (CAC/PKI \u2014 DoD)
|
|
2960
|
+
# \u26A0\uFE0F SECURITY REVIEW REQUIRED before production deployment
|
|
2961
|
+
CAC_CA_BUNDLE=./certs/dod-ca-bundle.pem
|
|
2962
|
+
CAC_OCSP_ENDPOINT=https://ocsp.disa.mil
|
|
2963
|
+
`;
|
|
2964
|
+
}
|
|
2965
|
+
if (method === "oidc") {
|
|
2966
|
+
const label = params.provider ?? "OIDC";
|
|
2967
|
+
const discoveryUrl = params.oidcDiscoveryUrl ?? "https://your-provider/.well-known/openid-configuration";
|
|
2968
|
+
return `# Authentication (OIDC \u2014 ${label})
|
|
2969
|
+
OIDC_DISCOVERY_URL=${discoveryUrl}
|
|
2970
|
+
OIDC_CLIENT_ID=your-client-id
|
|
2971
|
+
OIDC_CLIENT_SECRET=your-client-secret
|
|
2972
|
+
`;
|
|
2973
|
+
}
|
|
2974
|
+
const authUrl = params.oauth2AuthUrl ?? "https://your-auth-server/authorize";
|
|
2975
|
+
const tokenUrl = params.oauth2TokenUrl ?? "https://your-auth-server/token";
|
|
2976
|
+
return `# Authentication (OAuth2)
|
|
2977
|
+
OAUTH2_AUTH_URL=${authUrl}
|
|
2978
|
+
OAUTH2_TOKEN_URL=${tokenUrl}
|
|
2979
|
+
OAUTH2_CLIENT_ID=your-client-id
|
|
2980
|
+
OAUTH2_CLIENT_SECRET=your-client-secret
|
|
2981
|
+
`;
|
|
2982
|
+
}
|
|
2983
|
+
function generateYamlBlock(method, params, roles, defaultRole, hierarchy, auditEnabled, auditRetentionDays, protectedRoutes) {
|
|
2984
|
+
const rbacSection = ` rbac:
|
|
2985
|
+
roles:
|
|
2986
|
+
${rolesToYaml(roles, " ")}
|
|
2987
|
+
defaultRole: ${defaultRole}
|
|
2988
|
+
hierarchy:
|
|
2989
|
+
${hierarchyToYaml(hierarchy, " ")}`;
|
|
2990
|
+
const auditSection = ` audit:
|
|
2991
|
+
enabled: ${auditEnabled}
|
|
2992
|
+
retentionDays: ${auditRetentionDays}`;
|
|
2993
|
+
const routesSection = ` protectedRoutes:
|
|
2994
|
+
${routesToYaml(protectedRoutes, " ", defaultRole)}`.replace(/\n\s+,/g, "");
|
|
2995
|
+
const routeLines = protectedRoutes.map((r) => ` - pattern: ${r}
|
|
2996
|
+
requiredRole: ${defaultRole}`).join("\n");
|
|
2997
|
+
const providerLine = params.provider ? ` provider: ${params.provider}
|
|
2998
|
+
` : "";
|
|
2999
|
+
if (method === "cac") {
|
|
3000
|
+
const caBundle = params.cacCaBundle ?? "./certs/dod-ca-bundle.pem";
|
|
3001
|
+
const edipiLookup = params.cacEdipiLookup ?? "./config/edipi-lookup.json";
|
|
3002
|
+
const ocspEndpoint = params.cacOcspEndpoint ?? "https://ocsp.disa.mil";
|
|
3003
|
+
const certHeader = params.cacCertHeader ?? "X-SSL-Client-Cert";
|
|
3004
|
+
return `auth:
|
|
3005
|
+
method: cac
|
|
3006
|
+
${providerLine} middleware: ./middleware.ts
|
|
3007
|
+
cac:
|
|
3008
|
+
caBundle: \${CAC_CA_BUNDLE}
|
|
3009
|
+
edipiLookup: ${edipiLookup}
|
|
3010
|
+
ocspEndpoint: \${CAC_OCSP_ENDPOINT}
|
|
3011
|
+
certHeader: ${certHeader}
|
|
3012
|
+
${rbacSection}
|
|
3013
|
+
protectedRoutes:
|
|
3014
|
+
${routeLines}
|
|
3015
|
+
${auditSection}
|
|
3016
|
+
`;
|
|
3017
|
+
}
|
|
3018
|
+
if (method === "oidc") {
|
|
3019
|
+
const scopes2 = params.oidcScopes ?? "openid profile email";
|
|
3020
|
+
const roleClaim = params.oidcRoleClaim ?? "roles";
|
|
3021
|
+
return `auth:
|
|
3022
|
+
method: oidc
|
|
3023
|
+
${providerLine} middleware: ./middleware.ts
|
|
3024
|
+
oidc:
|
|
3025
|
+
discoveryUrl: \${OIDC_DISCOVERY_URL}
|
|
3026
|
+
clientId: \${OIDC_CLIENT_ID}
|
|
3027
|
+
clientSecret: \${OIDC_CLIENT_SECRET}
|
|
3028
|
+
scopes: ${scopes2}
|
|
3029
|
+
roleClaim: ${roleClaim}
|
|
3030
|
+
${rbacSection}
|
|
3031
|
+
protectedRoutes:
|
|
3032
|
+
${routeLines}
|
|
3033
|
+
${auditSection}
|
|
3034
|
+
`;
|
|
3035
|
+
}
|
|
3036
|
+
const scopes = params.oauth2Scopes ?? "read write";
|
|
3037
|
+
return `auth:
|
|
3038
|
+
method: oauth2
|
|
3039
|
+
${providerLine} middleware: ./middleware.ts
|
|
3040
|
+
oauth2:
|
|
3041
|
+
authorizationUrl: \${OAUTH2_AUTH_URL}
|
|
3042
|
+
tokenUrl: \${OAUTH2_TOKEN_URL}
|
|
3043
|
+
clientId: \${OAUTH2_CLIENT_ID}
|
|
3044
|
+
clientSecret: \${OAUTH2_CLIENT_SECRET}
|
|
3045
|
+
scopes: ${scopes}
|
|
3046
|
+
${rbacSection}
|
|
3047
|
+
protectedRoutes:
|
|
3048
|
+
${routeLines}
|
|
3049
|
+
${auditSection}
|
|
3050
|
+
`;
|
|
3051
|
+
}
|
|
3052
|
+
async function configureAuthHandler(params, cwd) {
|
|
3053
|
+
const {
|
|
3054
|
+
method,
|
|
3055
|
+
provider,
|
|
3056
|
+
rbacRoles = ["SUPER_ADMIN", "ADMIN", "ANALYST"],
|
|
3057
|
+
auditEnabled = true,
|
|
3058
|
+
auditRetentionDays = 90,
|
|
3059
|
+
protectedRoutes = ["/dashboard/:path*"]
|
|
3060
|
+
} = params;
|
|
3061
|
+
const roles = rbacRoles;
|
|
3062
|
+
const defaultRole = params.rbacDefaultRole ?? roles[roles.length - 1];
|
|
3063
|
+
const hierarchy = buildHierarchy(roles);
|
|
3064
|
+
if (method === "none") {
|
|
3065
|
+
return {
|
|
3066
|
+
content: [
|
|
3067
|
+
{
|
|
3068
|
+
type: "text",
|
|
3069
|
+
text: JSON.stringify({
|
|
3070
|
+
success: true,
|
|
3071
|
+
method: "none",
|
|
3072
|
+
provider: null,
|
|
3073
|
+
rbacRoles: roles,
|
|
3074
|
+
rbacDefaultRole: defaultRole,
|
|
3075
|
+
protectedRoutesCount: protectedRoutes.length,
|
|
3076
|
+
filesWritten: [],
|
|
3077
|
+
securityWarning: null
|
|
3078
|
+
})
|
|
3079
|
+
}
|
|
3080
|
+
]
|
|
3081
|
+
};
|
|
3082
|
+
}
|
|
3083
|
+
const filesWritten = [];
|
|
3084
|
+
try {
|
|
3085
|
+
const middlewareContent = generateMiddlewareContent(
|
|
3086
|
+
method,
|
|
3087
|
+
params,
|
|
3088
|
+
roles,
|
|
3089
|
+
defaultRole,
|
|
3090
|
+
hierarchy,
|
|
3091
|
+
auditEnabled,
|
|
3092
|
+
auditRetentionDays,
|
|
3093
|
+
protectedRoutes
|
|
3094
|
+
);
|
|
3095
|
+
(0, import_fs6.writeFileSync)((0, import_path6.join)(cwd, "middleware.ts"), middlewareContent, "utf8");
|
|
3096
|
+
filesWritten.push("middleware.ts");
|
|
3097
|
+
} catch (err) {
|
|
3098
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3099
|
+
return {
|
|
3100
|
+
content: [
|
|
3101
|
+
{
|
|
3102
|
+
type: "text",
|
|
3103
|
+
text: JSON.stringify({ success: false, error: `Failed writing middleware.ts: ${msg}` })
|
|
3104
|
+
}
|
|
3105
|
+
],
|
|
3106
|
+
isError: true
|
|
3107
|
+
};
|
|
3108
|
+
}
|
|
3109
|
+
try {
|
|
3110
|
+
const envBlock = generateEnvBlock(method, params);
|
|
3111
|
+
const envPath = (0, import_path6.join)(cwd, ".env.example");
|
|
3112
|
+
if ((0, import_fs6.existsSync)(envPath)) {
|
|
3113
|
+
const existing = (0, import_fs6.readFileSync)(envPath, "utf8");
|
|
3114
|
+
(0, import_fs6.writeFileSync)(envPath, existing.trimEnd() + "\n\n" + envBlock, "utf8");
|
|
3115
|
+
} else {
|
|
3116
|
+
(0, import_fs6.writeFileSync)(envPath, envBlock, "utf8");
|
|
3117
|
+
}
|
|
3118
|
+
filesWritten.push(".env.example");
|
|
3119
|
+
} catch (err) {
|
|
3120
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3121
|
+
return {
|
|
3122
|
+
content: [
|
|
3123
|
+
{
|
|
3124
|
+
type: "text",
|
|
3125
|
+
text: JSON.stringify({ success: false, error: `Failed writing .env.example: ${msg}` })
|
|
3126
|
+
}
|
|
3127
|
+
],
|
|
3128
|
+
isError: true
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
3131
|
+
try {
|
|
3132
|
+
const authYaml = generateYamlBlock(
|
|
3133
|
+
method,
|
|
3134
|
+
params,
|
|
3135
|
+
roles,
|
|
3136
|
+
defaultRole,
|
|
3137
|
+
hierarchy,
|
|
3138
|
+
auditEnabled,
|
|
3139
|
+
auditRetentionDays,
|
|
3140
|
+
protectedRoutes
|
|
3141
|
+
);
|
|
3142
|
+
const ymlPath = (0, import_path6.join)(cwd, "stackwright.yml");
|
|
3143
|
+
if (!(0, import_fs6.existsSync)(ymlPath)) {
|
|
3144
|
+
(0, import_fs6.writeFileSync)(ymlPath, authYaml, "utf8");
|
|
3145
|
+
} else {
|
|
3146
|
+
const existing = (0, import_fs6.readFileSync)(ymlPath, "utf8");
|
|
3147
|
+
(0, import_fs6.writeFileSync)(ymlPath, upsertAuthBlock(existing, authYaml), "utf8");
|
|
3148
|
+
}
|
|
3149
|
+
filesWritten.push("stackwright.yml");
|
|
3150
|
+
} catch (err) {
|
|
3151
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3152
|
+
return {
|
|
3153
|
+
content: [
|
|
3154
|
+
{
|
|
3155
|
+
type: "text",
|
|
3156
|
+
text: JSON.stringify({ success: false, error: `Failed writing stackwright.yml: ${msg}` })
|
|
3157
|
+
}
|
|
3158
|
+
],
|
|
3159
|
+
isError: true
|
|
3160
|
+
};
|
|
3161
|
+
}
|
|
3162
|
+
const securityWarning = method === "cac" ? "SECURITY REVIEW REQUIRED \u2014 CAC certificate chain must be verified before production deployment" : null;
|
|
3163
|
+
return {
|
|
3164
|
+
content: [
|
|
3165
|
+
{
|
|
3166
|
+
type: "text",
|
|
3167
|
+
text: JSON.stringify({
|
|
3168
|
+
success: true,
|
|
3169
|
+
method,
|
|
3170
|
+
provider: provider ?? null,
|
|
3171
|
+
rbacRoles: roles,
|
|
3172
|
+
rbacDefaultRole: defaultRole,
|
|
3173
|
+
protectedRoutesCount: protectedRoutes.length,
|
|
3174
|
+
filesWritten,
|
|
3175
|
+
securityWarning
|
|
3176
|
+
})
|
|
3177
|
+
}
|
|
3178
|
+
]
|
|
3179
|
+
};
|
|
3180
|
+
}
|
|
3181
|
+
function registerAuthTools(server2) {
|
|
3182
|
+
server2.tool(
|
|
3183
|
+
"stackwright_pro_configure_auth",
|
|
3184
|
+
"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.",
|
|
3185
|
+
{
|
|
3186
|
+
method: import_zod12.z.enum(["cac", "oidc", "oauth2", "none"]),
|
|
3187
|
+
provider: import_zod12.z.enum(["azure-ad", "okta", "ping", "cognito", "custom"]).optional(),
|
|
3188
|
+
// CAC
|
|
3189
|
+
cacCaBundle: import_zod12.z.string().optional(),
|
|
3190
|
+
cacEdipiLookup: import_zod12.z.string().optional(),
|
|
3191
|
+
cacOcspEndpoint: import_zod12.z.string().optional(),
|
|
3192
|
+
cacCertHeader: import_zod12.z.string().optional(),
|
|
3193
|
+
// OIDC
|
|
3194
|
+
oidcDiscoveryUrl: import_zod12.z.string().optional(),
|
|
3195
|
+
oidcClientId: import_zod12.z.string().optional(),
|
|
3196
|
+
oidcClientSecret: import_zod12.z.string().optional(),
|
|
3197
|
+
oidcScopes: import_zod12.z.string().optional(),
|
|
3198
|
+
oidcRoleClaim: import_zod12.z.string().optional(),
|
|
3199
|
+
// OAuth2
|
|
3200
|
+
oauth2AuthUrl: import_zod12.z.string().optional(),
|
|
3201
|
+
oauth2TokenUrl: import_zod12.z.string().optional(),
|
|
3202
|
+
oauth2ClientId: import_zod12.z.string().optional(),
|
|
3203
|
+
oauth2ClientSecret: import_zod12.z.string().optional(),
|
|
3204
|
+
oauth2Scopes: import_zod12.z.string().optional(),
|
|
3205
|
+
// RBAC
|
|
3206
|
+
rbacRoles: jsonCoerce(import_zod12.z.array(import_zod12.z.string()).optional()),
|
|
3207
|
+
rbacDefaultRole: import_zod12.z.string().optional(),
|
|
3208
|
+
// Audit
|
|
3209
|
+
auditEnabled: boolCoerce(import_zod12.z.boolean().optional()),
|
|
3210
|
+
auditRetentionDays: numCoerce(import_zod12.z.number().int().positive().optional()),
|
|
3211
|
+
// Routes
|
|
3212
|
+
protectedRoutes: jsonCoerce(import_zod12.z.array(import_zod12.z.string()).optional()),
|
|
3213
|
+
// Injection for tests
|
|
3214
|
+
_cwd: import_zod12.z.string().optional()
|
|
3215
|
+
},
|
|
3216
|
+
async (params) => {
|
|
3217
|
+
const cwd = params._cwd ?? process.cwd();
|
|
3218
|
+
return configureAuthHandler(params, cwd);
|
|
3219
|
+
}
|
|
3220
|
+
);
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
// src/integrity.ts
|
|
3224
|
+
var import_crypto2 = require("crypto");
|
|
3225
|
+
var import_fs7 = require("fs");
|
|
3226
|
+
var import_path7 = require("path");
|
|
3227
|
+
var _checksums = /* @__PURE__ */ new Map([
|
|
3228
|
+
[
|
|
3229
|
+
"stackwright-pro-api-otter.json",
|
|
3230
|
+
"1fd28747ff43121533d40d6446f2d2670d6247afb04e3025cbbcb9ace0e7d1e2"
|
|
3231
|
+
],
|
|
3232
|
+
[
|
|
3233
|
+
"stackwright-pro-auth-otter.json",
|
|
3234
|
+
"b5e901262d7b3f26ef390f1d3c9aadfa68376c05f5057edc241eb37b32b40afd"
|
|
3235
|
+
],
|
|
3236
|
+
[
|
|
3237
|
+
"stackwright-pro-dashboard-otter.json",
|
|
3238
|
+
"a9e50f26e8b2b687910685f15104b4e76a74ad2e1e5a6021237e1eeb1cbde2ae"
|
|
3239
|
+
],
|
|
3240
|
+
[
|
|
3241
|
+
"stackwright-pro-data-otter.json",
|
|
3242
|
+
"04b07f982f73a2904a1d92c6af3c58ecc132b474c57cab3eaec8566d718d2623"
|
|
3243
|
+
],
|
|
3244
|
+
[
|
|
3245
|
+
"stackwright-pro-designer-otter.json",
|
|
3246
|
+
"41c5b6b9f1f0f6eb0851e473f9d7d6ebd6a7e00dafd5cdeb8a8b12b0b756e245"
|
|
3247
|
+
],
|
|
3248
|
+
[
|
|
3249
|
+
"stackwright-pro-foreman-otter.json",
|
|
3250
|
+
"7c8af9ce5b157ad3030f0255218a6ea923df18a36fe44db9bd5f04897434fc05"
|
|
3251
|
+
],
|
|
3252
|
+
[
|
|
3253
|
+
"stackwright-pro-page-otter.json",
|
|
3254
|
+
"d672dc4dfd6a3b6d66c6cec93c8db6075dcd4c8f1e8d15e2704aca2fca6856a6"
|
|
3255
|
+
],
|
|
3256
|
+
[
|
|
3257
|
+
"stackwright-pro-theme-otter.json",
|
|
3258
|
+
"3a37d4bd696f142c4a4278ef653984fca4b776caa610182c2cb82f6732ef9b62"
|
|
3259
|
+
],
|
|
3260
|
+
[
|
|
3261
|
+
"stackwright-pro-workflow-otter.json",
|
|
3262
|
+
"fa2bae06e0f9e6b844008adc933d24b6a210708c0812ce068fc43733ee98b98e"
|
|
3263
|
+
]
|
|
3264
|
+
]);
|
|
3265
|
+
Object.freeze(_checksums);
|
|
3266
|
+
var CANONICAL_CHECKSUMS = _checksums;
|
|
3267
|
+
var SHA256_HEX_RE = /^[0-9a-f]{64}$/;
|
|
3268
|
+
for (const [name, digest] of CANONICAL_CHECKSUMS) {
|
|
3269
|
+
if (!SHA256_HEX_RE.test(digest)) {
|
|
3270
|
+
throw new Error(
|
|
3271
|
+
`Malformed SHA-256 in CANONICAL_CHECKSUMS for "${name}": expected 64 hex chars, got ${digest.length}: "${digest}"`
|
|
3272
|
+
);
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
var MAX_OTTER_BYTES = 1 * 1024 * 1024;
|
|
3276
|
+
function computeSha256(data) {
|
|
3277
|
+
return (0, import_crypto2.createHash)("sha256").update(data).digest("hex");
|
|
3278
|
+
}
|
|
3279
|
+
function safeEqual(a, b) {
|
|
3280
|
+
if (a.length !== b.length) return false;
|
|
3281
|
+
return (0, import_crypto2.timingSafeEqual)(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
3282
|
+
}
|
|
3283
|
+
function verifyOtterFile(filePath) {
|
|
3284
|
+
const filename = (0, import_path7.basename)(filePath);
|
|
3285
|
+
const expected = CANONICAL_CHECKSUMS.get(filename);
|
|
3286
|
+
if (expected === void 0) {
|
|
3287
|
+
return { verified: false, filename, error: `Unknown otter file: not in canonical set` };
|
|
3288
|
+
}
|
|
3289
|
+
let stat;
|
|
3290
|
+
try {
|
|
3291
|
+
stat = (0, import_fs7.lstatSync)(filePath);
|
|
3292
|
+
} catch (err) {
|
|
3293
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3294
|
+
return { verified: false, filename, error: `Cannot stat file: ${msg}` };
|
|
3295
|
+
}
|
|
3296
|
+
if (stat.isSymbolicLink()) {
|
|
3297
|
+
return { verified: false, filename, error: "Refusing to verify symlink" };
|
|
3298
|
+
}
|
|
3299
|
+
const size = stat.size;
|
|
3300
|
+
if (size > MAX_OTTER_BYTES) {
|
|
3301
|
+
return {
|
|
3302
|
+
verified: false,
|
|
3303
|
+
filename,
|
|
3304
|
+
error: `File exceeds size limit (${MAX_OTTER_BYTES.toLocaleString()} bytes, got ${size.toLocaleString()})`
|
|
3305
|
+
};
|
|
3306
|
+
}
|
|
3307
|
+
let raw;
|
|
3308
|
+
try {
|
|
3309
|
+
raw = (0, import_fs7.readFileSync)(filePath);
|
|
3310
|
+
} catch (err) {
|
|
3311
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3312
|
+
return { verified: false, filename, error: `Cannot read file: ${msg}` };
|
|
3313
|
+
}
|
|
3314
|
+
if (raw.length > MAX_OTTER_BYTES) {
|
|
3315
|
+
return {
|
|
3316
|
+
verified: false,
|
|
3317
|
+
filename,
|
|
3318
|
+
error: `File exceeds size limit after read (${MAX_OTTER_BYTES.toLocaleString()} bytes, got ${raw.length.toLocaleString()})`
|
|
3319
|
+
};
|
|
3320
|
+
}
|
|
3321
|
+
const actual = computeSha256(raw);
|
|
3322
|
+
if (!safeEqual(actual, expected)) {
|
|
3323
|
+
return {
|
|
3324
|
+
verified: false,
|
|
3325
|
+
filename,
|
|
3326
|
+
error: `SHA-256 mismatch: expected ${expected.substring(0, 8)}\u2026, got ${actual.substring(0, 8)}\u2026`
|
|
3327
|
+
};
|
|
3328
|
+
}
|
|
3329
|
+
try {
|
|
3330
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
3331
|
+
decoder.decode(raw);
|
|
3332
|
+
} catch {
|
|
3333
|
+
return {
|
|
3334
|
+
verified: false,
|
|
3335
|
+
filename,
|
|
3336
|
+
error: "File is not valid UTF-8 \u2014 may be corrupted or contain binary injection"
|
|
3337
|
+
};
|
|
3338
|
+
}
|
|
3339
|
+
return { verified: true, filename };
|
|
3340
|
+
}
|
|
3341
|
+
function verifyAllOtters(otterDir) {
|
|
3342
|
+
const verified = [];
|
|
3343
|
+
const failed = [];
|
|
3344
|
+
const unknown = [];
|
|
3345
|
+
let entries;
|
|
3346
|
+
try {
|
|
3347
|
+
entries = (0, import_fs7.readdirSync)(otterDir);
|
|
3348
|
+
} catch (err) {
|
|
3349
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3350
|
+
return {
|
|
3351
|
+
verified: [],
|
|
3352
|
+
failed: [{ filename: "<directory>", error: `Cannot read directory: ${msg}` }],
|
|
3353
|
+
unknown: []
|
|
3354
|
+
};
|
|
3355
|
+
}
|
|
3356
|
+
const otterFiles = entries.filter((f) => f.endsWith("-otter.json"));
|
|
3357
|
+
for (const filename of otterFiles) {
|
|
3358
|
+
const filePath = (0, import_path7.join)(otterDir, filename);
|
|
3359
|
+
try {
|
|
3360
|
+
if ((0, import_fs7.lstatSync)(filePath).isSymbolicLink()) {
|
|
3361
|
+
failed.push({ filename, error: "Skipped: symlink" });
|
|
3362
|
+
continue;
|
|
3363
|
+
}
|
|
3364
|
+
} catch {
|
|
3365
|
+
}
|
|
3366
|
+
const result = verifyOtterFile(filePath);
|
|
3367
|
+
if (result.verified) {
|
|
3368
|
+
verified.push(result.filename);
|
|
3369
|
+
} else if (result.error?.startsWith("Unknown otter file")) {
|
|
3370
|
+
unknown.push(result.filename);
|
|
3371
|
+
} else {
|
|
3372
|
+
failed.push({ filename: result.filename, error: result.error ?? "Unknown error" });
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
for (const canonicalName of CANONICAL_CHECKSUMS.keys()) {
|
|
3376
|
+
if (!otterFiles.includes(canonicalName)) {
|
|
3377
|
+
failed.push({ filename: canonicalName, error: "Missing from directory" });
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
return { verified, failed, unknown };
|
|
3381
|
+
}
|
|
3382
|
+
var DEFAULT_SEARCH_PATHS = ["node_modules/@stackwright-pro/otters/src/", "packages/otters/src/"];
|
|
3383
|
+
function resolveOtterDir() {
|
|
3384
|
+
const cwd = process.cwd();
|
|
3385
|
+
for (const relative of DEFAULT_SEARCH_PATHS) {
|
|
3386
|
+
const candidate = (0, import_path7.join)(cwd, relative);
|
|
3387
|
+
try {
|
|
3388
|
+
(0, import_fs7.lstatSync)(candidate);
|
|
3389
|
+
return candidate;
|
|
3390
|
+
} catch {
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
return null;
|
|
3394
|
+
}
|
|
3395
|
+
function registerIntegrityTools(server2) {
|
|
3396
|
+
server2.tool(
|
|
3397
|
+
"stackwright_pro_verify_otter_integrity",
|
|
3398
|
+
"Verify SHA-256 integrity of all Pro otter agent definitions. Call this at startup before discovering otters. Auto-discovers the otter directory from known paths. Returns verified/failed/unknown lists.",
|
|
3399
|
+
{},
|
|
3400
|
+
async () => {
|
|
3401
|
+
const resolved = resolveOtterDir();
|
|
3402
|
+
if (!resolved) {
|
|
3403
|
+
return {
|
|
3404
|
+
content: [
|
|
3405
|
+
{
|
|
3406
|
+
type: "text",
|
|
3407
|
+
text: JSON.stringify({
|
|
3408
|
+
error: true,
|
|
3409
|
+
message: "Could not locate otter directory. Searched: " + DEFAULT_SEARCH_PATHS.join(", ")
|
|
3410
|
+
})
|
|
3411
|
+
}
|
|
3412
|
+
],
|
|
3413
|
+
isError: true
|
|
3414
|
+
};
|
|
3415
|
+
}
|
|
3416
|
+
const result = verifyAllOtters(resolved);
|
|
3417
|
+
const allGood = result.failed.length === 0 && result.unknown.length === 0;
|
|
3418
|
+
return {
|
|
3419
|
+
content: [
|
|
3420
|
+
{
|
|
3421
|
+
type: "text",
|
|
3422
|
+
text: JSON.stringify({
|
|
3423
|
+
otterDir: resolved,
|
|
3424
|
+
totalCanonical: CANONICAL_CHECKSUMS.size,
|
|
3425
|
+
verifiedCount: result.verified.length,
|
|
3426
|
+
failedCount: result.failed.length,
|
|
3427
|
+
unknownCount: result.unknown.length,
|
|
3428
|
+
verified: result.verified,
|
|
3429
|
+
failed: result.failed,
|
|
3430
|
+
unknown: result.unknown,
|
|
3431
|
+
warning: result.failed.length > 0 ? "SHA-256 mismatches detected (non-blocking). PKI-signed manifest support coming soon." : void 0
|
|
3432
|
+
})
|
|
3433
|
+
}
|
|
3434
|
+
],
|
|
3435
|
+
isError: false
|
|
3436
|
+
};
|
|
3437
|
+
}
|
|
3438
|
+
);
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
// src/tools/domain.ts
|
|
3442
|
+
var import_zod13 = require("zod");
|
|
3443
|
+
var import_fs8 = require("fs");
|
|
3444
|
+
var import_path8 = require("path");
|
|
3445
|
+
function handleListCollections(input) {
|
|
3446
|
+
const cwd = input._cwd ?? process.cwd();
|
|
3447
|
+
const sources = [
|
|
3448
|
+
{
|
|
3449
|
+
path: (0, import_path8.join)(cwd, ".stackwright", "artifacts", "data-config.json"),
|
|
3450
|
+
source: "data-config.json",
|
|
3451
|
+
parse: (raw) => {
|
|
3452
|
+
const parsed = JSON.parse(raw);
|
|
3453
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
3454
|
+
return [];
|
|
3455
|
+
}
|
|
3456
|
+
return extractCollectionsFromArtifact(parsed);
|
|
3457
|
+
}
|
|
3458
|
+
},
|
|
3459
|
+
{
|
|
3460
|
+
path: (0, import_path8.join)(cwd, "stackwright.yml"),
|
|
3461
|
+
source: "stackwright.yml",
|
|
3462
|
+
parse: extractCollectionsFromYaml
|
|
3463
|
+
}
|
|
3464
|
+
];
|
|
3465
|
+
for (const { path: path3, source, parse } of sources) {
|
|
3466
|
+
if (!(0, import_fs8.existsSync)(path3)) continue;
|
|
3467
|
+
try {
|
|
3468
|
+
const collections = parse((0, import_fs8.readFileSync)(path3, "utf8"));
|
|
3469
|
+
return {
|
|
3470
|
+
text: JSON.stringify({ collections, source, collectionCount: collections.length }),
|
|
3471
|
+
isError: false
|
|
3472
|
+
};
|
|
3473
|
+
} catch {
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
return {
|
|
3477
|
+
text: JSON.stringify({
|
|
3478
|
+
collections: [],
|
|
3479
|
+
source: "none",
|
|
3480
|
+
collectionCount: 0,
|
|
3481
|
+
hint: "Run API Otter and Data Otter first"
|
|
3482
|
+
}),
|
|
3483
|
+
isError: false
|
|
3484
|
+
};
|
|
3485
|
+
}
|
|
3486
|
+
function extractCollectionsFromArtifact(raw) {
|
|
3487
|
+
const collections = [];
|
|
3488
|
+
const integrations = raw.integrations ?? raw.collections ?? [];
|
|
3489
|
+
if (!Array.isArray(integrations)) return collections;
|
|
3490
|
+
for (const item of integrations) {
|
|
3491
|
+
if (!item || typeof item !== "object") continue;
|
|
3492
|
+
const obj = item;
|
|
3493
|
+
if (typeof obj.name === "string" && typeof obj.endpoint === "string") {
|
|
3494
|
+
collections.push(makeCollectionInfo(obj.name, obj.endpoint, obj.type));
|
|
3495
|
+
continue;
|
|
3496
|
+
}
|
|
3497
|
+
if (!Array.isArray(obj.collections)) continue;
|
|
3498
|
+
for (const col of obj.collections) {
|
|
3499
|
+
if (!col || typeof col !== "object") continue;
|
|
3500
|
+
const c = col;
|
|
3501
|
+
if (typeof c.name === "string" && typeof c.endpoint === "string") {
|
|
3502
|
+
collections.push(makeCollectionInfo(c.name, c.endpoint, c.type ?? obj.type));
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
return collections;
|
|
3507
|
+
}
|
|
3508
|
+
function makeCollectionInfo(name, endpoint, type) {
|
|
3509
|
+
return { name, endpoint, ...typeof type === "string" ? { type } : {} };
|
|
3510
|
+
}
|
|
3511
|
+
function extractCollectionsFromYaml(yamlText) {
|
|
3512
|
+
const collections = [];
|
|
3513
|
+
const lines = yamlText.split("\n");
|
|
3514
|
+
let inIntegrations = false;
|
|
3515
|
+
let currentName = null;
|
|
3516
|
+
let currentEndpoint = null;
|
|
3517
|
+
let currentType = null;
|
|
3518
|
+
for (const line of lines) {
|
|
3519
|
+
if (line.length > 1e3) continue;
|
|
3520
|
+
if (/^integrations:\s*$/.test(line)) {
|
|
3521
|
+
inIntegrations = true;
|
|
3522
|
+
continue;
|
|
3523
|
+
}
|
|
3524
|
+
if (inIntegrations && /^[a-z]/.test(line) && !line.startsWith(" ")) {
|
|
3525
|
+
inIntegrations = false;
|
|
3526
|
+
}
|
|
3527
|
+
if (!inIntegrations) continue;
|
|
3528
|
+
const stripQuotes = (s) => s.trim().replace(/^['"]|['"]$/g, "");
|
|
3529
|
+
const typeMatch = line.match(/^\s+type:\s*(.+)$/);
|
|
3530
|
+
if (typeMatch?.[1]) currentType = stripQuotes(typeMatch[1]);
|
|
3531
|
+
const nameMatch = line.match(/^[\s-]+name:\s*(.+)$/);
|
|
3532
|
+
if (nameMatch?.[1]) {
|
|
3533
|
+
if (currentName && currentEndpoint) {
|
|
3534
|
+
collections.push(makeCollectionInfo(currentName, currentEndpoint, currentType));
|
|
3535
|
+
}
|
|
3536
|
+
currentName = stripQuotes(nameMatch[1]);
|
|
3537
|
+
currentEndpoint = null;
|
|
3538
|
+
}
|
|
3539
|
+
const endpointMatch = line.match(/^\s+endpoint:\s*(.+)$/);
|
|
3540
|
+
if (endpointMatch?.[1]) currentEndpoint = stripQuotes(endpointMatch[1]);
|
|
3541
|
+
}
|
|
3542
|
+
if (currentName && currentEndpoint) {
|
|
3543
|
+
collections.push(makeCollectionInfo(currentName, currentEndpoint, currentType));
|
|
3544
|
+
}
|
|
3545
|
+
return collections;
|
|
3546
|
+
}
|
|
3547
|
+
var DATA_STRATEGIES = {
|
|
3548
|
+
"pulse-fast": {
|
|
3549
|
+
strategy: "pulse-fast",
|
|
3550
|
+
mechanism: "Client-side polling via @stackwright-pro/pulse",
|
|
3551
|
+
mechanismPackage: "@stackwright-pro/pulse",
|
|
3552
|
+
pulse: true,
|
|
3553
|
+
requiredPackages: { "@stackwright-pro/pulse": "latest", "@tanstack/react-query": "^5.0.0" },
|
|
3554
|
+
handoffFlags: ["PULSE_MODE=true"],
|
|
3555
|
+
description: "Real-time updates every few seconds. Uses client-side polling. Dashboard Otter should use *_pulse component variants."
|
|
3556
|
+
},
|
|
3557
|
+
"isr-fast": {
|
|
3558
|
+
strategy: "isr-fast",
|
|
3559
|
+
mechanism: "Next.js ISR",
|
|
3560
|
+
revalidateSeconds: 60,
|
|
3561
|
+
pulse: false,
|
|
3562
|
+
requiredPackages: {},
|
|
3563
|
+
handoffFlags: [],
|
|
3564
|
+
description: "Near real-time with 60-second ISR revalidation. Good for dashboards that need minute-level freshness."
|
|
3565
|
+
},
|
|
3566
|
+
"isr-standard": {
|
|
3567
|
+
strategy: "isr-standard",
|
|
3568
|
+
mechanism: "Next.js ISR",
|
|
3569
|
+
revalidateSeconds: 3600,
|
|
3570
|
+
pulse: false,
|
|
3571
|
+
requiredPackages: {},
|
|
3572
|
+
handoffFlags: [],
|
|
3573
|
+
description: "Standard hourly revalidation. Good for most API-backed pages."
|
|
3574
|
+
},
|
|
3575
|
+
"isr-slow": {
|
|
3576
|
+
strategy: "isr-slow",
|
|
3577
|
+
mechanism: "Next.js ISR",
|
|
3578
|
+
revalidateSeconds: 86400,
|
|
3579
|
+
pulse: false,
|
|
3580
|
+
requiredPackages: {},
|
|
3581
|
+
handoffFlags: [],
|
|
3582
|
+
description: "Daily revalidation. Good for infrequently changing data."
|
|
3583
|
+
}
|
|
3584
|
+
};
|
|
3585
|
+
function handleResolveDataStrategy(input) {
|
|
3586
|
+
const key = input.strategy.trim().toLowerCase();
|
|
3587
|
+
const match = DATA_STRATEGIES[key];
|
|
3588
|
+
if (!match) {
|
|
3589
|
+
const validKeys = Object.keys(DATA_STRATEGIES).join(", ");
|
|
3590
|
+
return {
|
|
3591
|
+
text: JSON.stringify({
|
|
3592
|
+
error: true,
|
|
3593
|
+
message: `Unknown strategy: "${input.strategy}". Valid strategies: ${validKeys}`,
|
|
3594
|
+
validStrategies: Object.keys(DATA_STRATEGIES)
|
|
3595
|
+
}),
|
|
3596
|
+
isError: true
|
|
3597
|
+
};
|
|
3598
|
+
}
|
|
3599
|
+
const configureIsrCall = match.pulse ? null : {
|
|
3600
|
+
tool: "stackwright_pro_configure_isr_batch",
|
|
3601
|
+
args: {
|
|
3602
|
+
collections: [{ name: "$COLLECTION", revalidateSeconds: match.revalidateSeconds }]
|
|
3603
|
+
}
|
|
3604
|
+
};
|
|
3605
|
+
return {
|
|
3606
|
+
text: JSON.stringify({ ...match, configureIsrCall }),
|
|
3607
|
+
isError: false
|
|
3608
|
+
};
|
|
3609
|
+
}
|
|
3610
|
+
function fail(errors) {
|
|
3611
|
+
return { text: JSON.stringify({ valid: false, errors, warnings: [] }), isError: true };
|
|
3612
|
+
}
|
|
3613
|
+
function handleValidateWorkflow(input) {
|
|
3614
|
+
const cwd = input._cwd ?? process.cwd();
|
|
3615
|
+
let raw;
|
|
3616
|
+
if (input.workflow && Object.keys(input.workflow).length > 0) {
|
|
3617
|
+
raw = input.workflow;
|
|
3618
|
+
} else {
|
|
3619
|
+
const artifactPath = (0, import_path8.join)(cwd, ".stackwright", "artifacts", "workflow-config.json");
|
|
3620
|
+
if (!(0, import_fs8.existsSync)(artifactPath)) {
|
|
3621
|
+
return fail([
|
|
3622
|
+
{
|
|
3623
|
+
code: "NO_WORKFLOW",
|
|
3624
|
+
message: "No workflow provided and .stackwright/artifacts/workflow-config.json not found. Pass a workflow object or run the workflow otter first."
|
|
3625
|
+
}
|
|
3626
|
+
]);
|
|
3627
|
+
}
|
|
3628
|
+
try {
|
|
3629
|
+
raw = JSON.parse((0, import_fs8.readFileSync)(artifactPath, "utf8"));
|
|
3630
|
+
} catch (err) {
|
|
3631
|
+
return fail([{ code: "INVALID_JSON", message: `Failed to parse workflow artifact: ${err}` }]);
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
const workflow = raw.workflow && typeof raw.workflow === "object" ? raw.workflow : raw;
|
|
3635
|
+
const errors = [];
|
|
3636
|
+
const warnings = [];
|
|
3637
|
+
if (typeof workflow.id !== "string" || !workflow.id) {
|
|
3638
|
+
errors.push({ code: "MISSING_ID", message: "workflow.id is required", path: "workflow.id" });
|
|
3639
|
+
} else if (!/^[a-z0-9-]+$/.test(workflow.id)) {
|
|
3640
|
+
errors.push({
|
|
3641
|
+
code: "INVALID_ID",
|
|
3642
|
+
message: `workflow.id "${workflow.id}" must match ^[a-z0-9-]+$`,
|
|
3643
|
+
path: "workflow.id"
|
|
3644
|
+
});
|
|
3645
|
+
}
|
|
3646
|
+
if (typeof workflow.label !== "string" || !workflow.label) {
|
|
3647
|
+
errors.push({
|
|
3648
|
+
code: "MISSING_LABEL",
|
|
3649
|
+
message: "workflow.label is required",
|
|
3650
|
+
path: "workflow.label"
|
|
3651
|
+
});
|
|
3652
|
+
}
|
|
3653
|
+
const steps = workflow.steps;
|
|
3654
|
+
if (!Array.isArray(steps)) {
|
|
3655
|
+
errors.push({
|
|
3656
|
+
code: "MISSING_STEPS",
|
|
3657
|
+
message: "workflow.steps must be an array",
|
|
3658
|
+
path: "workflow.steps"
|
|
3659
|
+
});
|
|
3660
|
+
return { text: JSON.stringify({ valid: false, errors, warnings }), isError: false };
|
|
3661
|
+
}
|
|
3662
|
+
if (steps.length < 2) {
|
|
3663
|
+
errors.push({
|
|
3664
|
+
code: "TOO_FEW_STEPS",
|
|
3665
|
+
message: "A workflow must have at least 2 steps",
|
|
3666
|
+
path: "workflow.steps"
|
|
3667
|
+
});
|
|
3668
|
+
}
|
|
3669
|
+
const stepIds = /* @__PURE__ */ new Set();
|
|
3670
|
+
const duplicateIds = [];
|
|
3671
|
+
for (const step of steps) {
|
|
3672
|
+
if (!step || typeof step !== "object") continue;
|
|
3673
|
+
const id = step.id;
|
|
3674
|
+
if (typeof id !== "string" || !id) {
|
|
3675
|
+
errors.push({
|
|
3676
|
+
code: "MISSING_STEP_ID",
|
|
3677
|
+
message: "Every step must have an id",
|
|
3678
|
+
path: "workflow.steps"
|
|
3679
|
+
});
|
|
3680
|
+
continue;
|
|
3681
|
+
}
|
|
3682
|
+
if (!/^[a-z0-9_]+$/.test(id)) {
|
|
3683
|
+
errors.push({
|
|
3684
|
+
code: "INVALID_STEP_ID",
|
|
3685
|
+
message: `Step ID "${id}" must match ^[a-z0-9_]+$`,
|
|
3686
|
+
path: `workflow.steps[${id}].id`
|
|
3687
|
+
});
|
|
3688
|
+
}
|
|
3689
|
+
if (stepIds.has(id)) duplicateIds.push(id);
|
|
3690
|
+
stepIds.add(id);
|
|
3691
|
+
}
|
|
3692
|
+
if (duplicateIds.length > 0) {
|
|
3693
|
+
errors.push({
|
|
3694
|
+
code: "DUPLICATE_STEP_IDS",
|
|
3695
|
+
message: `Duplicate step IDs: ${duplicateIds.join(", ")}`,
|
|
3696
|
+
path: "workflow.steps"
|
|
3697
|
+
});
|
|
3698
|
+
}
|
|
3699
|
+
const initialStep = workflow.initial_step;
|
|
3700
|
+
if (typeof initialStep !== "string" || !initialStep) {
|
|
3701
|
+
errors.push({
|
|
3702
|
+
code: "MISSING_INITIAL_STEP",
|
|
3703
|
+
message: "workflow.initial_step is required",
|
|
3704
|
+
path: "workflow.initial_step"
|
|
3705
|
+
});
|
|
3706
|
+
} else if (!stepIds.has(initialStep)) {
|
|
3707
|
+
errors.push({
|
|
3708
|
+
code: "INVALID_INITIAL_STEP",
|
|
3709
|
+
message: `initial_step "${initialStep}" does not reference an existing step. Valid: ${[...stepIds].join(", ")}`,
|
|
3710
|
+
path: "workflow.initial_step"
|
|
3711
|
+
});
|
|
3712
|
+
}
|
|
3713
|
+
for (const step of steps) {
|
|
3714
|
+
if (!step || typeof step !== "object") continue;
|
|
3715
|
+
const s = step;
|
|
3716
|
+
const stepId = String(s.id ?? "??");
|
|
3717
|
+
validateTransitionTargets(s, stepId, stepIds, errors);
|
|
3718
|
+
collectServiceWarnings(s, stepId, warnings);
|
|
3719
|
+
}
|
|
3720
|
+
if (typeof workflow.persistence === "string" && workflow.persistence.startsWith("service:")) {
|
|
3721
|
+
warnings.push({
|
|
3722
|
+
code: "WARN_SERVICE_REFERENCE",
|
|
3723
|
+
message: 'service: reference at "workflow.persistence" requires @stackwright-pro/services. Prism mock fallback will be used until services layer is configured.',
|
|
3724
|
+
path: "workflow.persistence"
|
|
3725
|
+
});
|
|
3726
|
+
}
|
|
3727
|
+
const hasTerminal = steps.some((step) => {
|
|
3728
|
+
if (!step || typeof step !== "object") return false;
|
|
3729
|
+
return step.type === "terminal";
|
|
3730
|
+
});
|
|
3731
|
+
if (!hasTerminal) {
|
|
3732
|
+
errors.push({
|
|
3733
|
+
code: "NO_TERMINAL_STATE",
|
|
3734
|
+
message: "Workflow must have at least one step with type: terminal",
|
|
3735
|
+
path: "workflow.steps"
|
|
3736
|
+
});
|
|
3737
|
+
}
|
|
3738
|
+
return { text: JSON.stringify({ valid: errors.length === 0, errors, warnings }), isError: false };
|
|
3739
|
+
}
|
|
3740
|
+
function validateTransitionTargets(step, stepId, stepIds, errors) {
|
|
3741
|
+
const check = (target, path3) => {
|
|
3742
|
+
if (typeof target === "string" && !stepIds.has(target)) {
|
|
3743
|
+
errors.push({
|
|
3744
|
+
code: "ORPHANED_TRANSITION",
|
|
3745
|
+
message: `Step "${stepId}" transitions to "${target}" which does not exist`,
|
|
3746
|
+
path: path3
|
|
3747
|
+
});
|
|
3748
|
+
}
|
|
3749
|
+
};
|
|
3750
|
+
const onSubmit = step.on_submit;
|
|
3751
|
+
if (onSubmit && typeof onSubmit === "object") {
|
|
3752
|
+
check(onSubmit.transition, `workflow.steps[${stepId}].on_submit.transition`);
|
|
3753
|
+
}
|
|
3754
|
+
if (Array.isArray(step.actions)) {
|
|
3755
|
+
for (const action of step.actions) {
|
|
3756
|
+
if (!action || typeof action !== "object") continue;
|
|
3757
|
+
const a = action;
|
|
3758
|
+
check(a.transition, `workflow.steps[${stepId}].actions[${a.id ?? "??"}].transition`);
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3761
|
+
if (Array.isArray(step.conditions)) {
|
|
3762
|
+
for (const cond of step.conditions) {
|
|
3763
|
+
if (!cond || typeof cond !== "object") continue;
|
|
3764
|
+
const then = cond.then;
|
|
3765
|
+
if (then && typeof then === "object") {
|
|
3766
|
+
check(then.transition, `workflow.steps[${stepId}].conditions`);
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
}
|
|
3770
|
+
if (Array.isArray(step.show_fields_from)) {
|
|
3771
|
+
for (const ref of step.show_fields_from)
|
|
3772
|
+
check(ref, `workflow.steps[${stepId}].show_fields_from`);
|
|
3773
|
+
}
|
|
3774
|
+
const display = step.display;
|
|
3775
|
+
if (display && typeof display === "object") {
|
|
3776
|
+
check(display.source_step, `workflow.steps[${stepId}].display.source_step`);
|
|
3777
|
+
if (Array.isArray(display.source_steps)) {
|
|
3778
|
+
for (const ref of display.source_steps)
|
|
3779
|
+
check(ref, `workflow.steps[${stepId}].display.source_steps`);
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
function collectServiceWarnings(step, stepId, warnings) {
|
|
3784
|
+
const isService = (val) => typeof val === "string" && val.startsWith("service:");
|
|
3785
|
+
const warn = (path3) => {
|
|
3786
|
+
warnings.push({
|
|
3787
|
+
code: "WARN_SERVICE_REFERENCE",
|
|
3788
|
+
message: `service: reference at "${path3}" requires @stackwright-pro/services. Prism mock fallback will be used until services layer is configured.`,
|
|
3789
|
+
path: path3
|
|
3790
|
+
});
|
|
3791
|
+
};
|
|
3792
|
+
const onSubmit = step.on_submit;
|
|
3793
|
+
if (onSubmit && typeof onSubmit === "object" && isService(onSubmit.action))
|
|
3794
|
+
warn(`${stepId}.on_submit.action`);
|
|
3795
|
+
const onEnter = step.on_enter;
|
|
3796
|
+
if (onEnter && typeof onEnter === "object" && isService(onEnter.action))
|
|
3797
|
+
warn(`${stepId}.on_enter.action`);
|
|
3798
|
+
if (Array.isArray(step.actions)) {
|
|
3799
|
+
for (const action of step.actions) {
|
|
3800
|
+
if (!action || typeof action !== "object") continue;
|
|
3801
|
+
const a = action;
|
|
3802
|
+
if (isService(a.action)) warn(`${stepId}.actions.${a.id ?? "??"}.action`);
|
|
3803
|
+
}
|
|
3804
|
+
}
|
|
3805
|
+
if (Array.isArray(step.fields)) {
|
|
3806
|
+
for (const field of step.fields) {
|
|
3807
|
+
if (!field || typeof field !== "object") continue;
|
|
3808
|
+
const f = field;
|
|
3809
|
+
if (isService(f.data_source)) warn(`${stepId}.fields.${f.name ?? "??"}.data_source`);
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
function registerDomainTools(server2) {
|
|
3814
|
+
const res = (r) => ({
|
|
3815
|
+
content: [{ type: "text", text: r.text }],
|
|
3816
|
+
isError: r.isError
|
|
3817
|
+
});
|
|
3818
|
+
server2.tool(
|
|
3819
|
+
"stackwright_pro_list_collections",
|
|
3820
|
+
"List API-backed collections available for page generation. Reads from Data Otter artifact (.stackwright/artifacts/data-config.json) or stackwright.yml. Call this before generating pages that bind to data.",
|
|
3821
|
+
{},
|
|
3822
|
+
async () => res(handleListCollections({}))
|
|
3823
|
+
);
|
|
3824
|
+
server2.tool(
|
|
3825
|
+
"stackwright_pro_resolve_data_strategy",
|
|
3826
|
+
"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.",
|
|
3827
|
+
{
|
|
3828
|
+
strategy: import_zod13.z.string().describe(
|
|
3829
|
+
'The data-1 answer value: "pulse-fast", "isr-fast", "isr-standard", or "isr-slow"'
|
|
3830
|
+
)
|
|
3831
|
+
},
|
|
3832
|
+
async ({ strategy }) => res(handleResolveDataStrategy({ strategy }))
|
|
3833
|
+
);
|
|
3834
|
+
server2.tool(
|
|
3835
|
+
"stackwright_pro_validate_workflow",
|
|
3836
|
+
"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.",
|
|
3837
|
+
{
|
|
3838
|
+
workflow: jsonCoerce(import_zod13.z.record(import_zod13.z.string(), import_zod13.z.unknown()).optional()).describe(
|
|
3839
|
+
"Parsed workflow object. If omitted, reads from .stackwright/artifacts/workflow-config.json"
|
|
3840
|
+
)
|
|
3841
|
+
},
|
|
3842
|
+
async ({ workflow }) => res(handleValidateWorkflow(workflow ? { workflow } : {}))
|
|
3843
|
+
);
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
// src/tools/type-schemas.ts
|
|
3847
|
+
var import_zod14 = require("zod");
|
|
3848
|
+
function buildTypeSchemaSummary() {
|
|
3849
|
+
return {
|
|
3850
|
+
version: "1.0",
|
|
3851
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3852
|
+
domains: {
|
|
3853
|
+
workflow: {
|
|
3854
|
+
description: "Workflow DSL \u2014 step definitions, auth blocks, field types, conditions",
|
|
3855
|
+
schemas: [
|
|
3856
|
+
"WorkflowFileSchema",
|
|
3857
|
+
"WorkflowDefinitionSchema",
|
|
3858
|
+
"WorkflowStepSchema",
|
|
3859
|
+
"WorkflowStepTypeSchema",
|
|
3860
|
+
"WorkflowFieldSchema",
|
|
3861
|
+
"WorkflowActionSchema",
|
|
3862
|
+
"WorkflowAuthSchema",
|
|
3863
|
+
"WorkflowThemeSchema",
|
|
3864
|
+
"TransitionConditionSchema",
|
|
3865
|
+
"PersistenceSchema"
|
|
3866
|
+
],
|
|
3867
|
+
otter: "stackwright-pro-workflow-otter",
|
|
3868
|
+
artifactKey: "workflowConfig"
|
|
3869
|
+
},
|
|
3870
|
+
auth: {
|
|
3871
|
+
description: "Authentication providers \u2014 PKI/CAC, OIDC, RBAC configuration",
|
|
3872
|
+
schemas: [
|
|
3873
|
+
"authConfigSchema",
|
|
3874
|
+
"pkiConfigSchema",
|
|
3875
|
+
"oidcConfigSchema",
|
|
3876
|
+
"rbacConfigSchema",
|
|
3877
|
+
"componentAuthSchema",
|
|
3878
|
+
"authUserSchema",
|
|
3879
|
+
"authSessionSchema"
|
|
3880
|
+
],
|
|
3881
|
+
otter: "stackwright-pro-auth-otter",
|
|
3882
|
+
artifactKey: "authConfig"
|
|
3883
|
+
},
|
|
3884
|
+
openapi: {
|
|
3885
|
+
description: "OpenAPI spec integration \u2014 collection config, endpoint filters, actions",
|
|
3886
|
+
interfaces: [
|
|
3887
|
+
"OpenAPIConfig",
|
|
3888
|
+
"ActionConfig",
|
|
3889
|
+
"EndpointFilter",
|
|
3890
|
+
"ApprovedSpec",
|
|
3891
|
+
"PrebuildSecurityConfig",
|
|
3892
|
+
"SiteConfig",
|
|
3893
|
+
"ValidationResult"
|
|
3894
|
+
],
|
|
3895
|
+
otter: "stackwright-pro-api-otter",
|
|
3896
|
+
artifactKey: "apiConfig"
|
|
3897
|
+
},
|
|
3898
|
+
pulse: {
|
|
3899
|
+
description: "Real-time data polling \u2014 source-agnostic polling options and states",
|
|
3900
|
+
interfaces: ["PulseOptions", "PulseMeta", "PulseState"],
|
|
3901
|
+
note: "React-bound types (PulseProps, PulseIndicatorProps) remain in @stackwright-pro/pulse"
|
|
3902
|
+
},
|
|
3903
|
+
enterprise: {
|
|
3904
|
+
description: "Enterprise collection access \u2014 multi-tenant provider extension",
|
|
3905
|
+
interfaces: [
|
|
3906
|
+
"EnterpriseCollectionProvider",
|
|
3907
|
+
"CollectionProvider",
|
|
3908
|
+
"CollectionEntry",
|
|
3909
|
+
"CollectionListOptions",
|
|
3910
|
+
"CollectionListResult",
|
|
3911
|
+
"TenantFilter",
|
|
3912
|
+
"Collection"
|
|
3913
|
+
],
|
|
3914
|
+
note: "CollectionProvider, CollectionEntry, CollectionListOptions, and CollectionListResult are re-exported from @stackwright/types (^1.5.0). EnterpriseCollectionProvider, TenantFilter, and Collection are Pro-only extensions."
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
};
|
|
3918
|
+
}
|
|
3919
|
+
function registerTypeSchemasTool(server2) {
|
|
3920
|
+
server2.tool(
|
|
3921
|
+
"stackwright_pro_get_type_schemas",
|
|
3922
|
+
"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.",
|
|
3923
|
+
{
|
|
3924
|
+
format: import_zod14.z.enum(["full", "domains-only"]).optional().default("full").describe("full = complete summary with all fields; domains-only = just domain names")
|
|
3925
|
+
},
|
|
3926
|
+
async ({ format }) => {
|
|
3927
|
+
const summary = buildTypeSchemaSummary();
|
|
3928
|
+
const output = format === "domains-only" ? Object.keys(summary.domains) : summary;
|
|
3929
|
+
return {
|
|
3930
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
3931
|
+
};
|
|
3932
|
+
}
|
|
3933
|
+
);
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
// package.json
|
|
3937
|
+
var package_default = {
|
|
3938
|
+
dependencies: {
|
|
3939
|
+
"@stackwright-pro/types": "workspace:*",
|
|
3940
|
+
"@modelcontextprotocol/sdk": "^1.10.0",
|
|
3941
|
+
"@stackwright-pro/cli-data-explorer": "workspace:*",
|
|
3942
|
+
zod: "^4.3.6"
|
|
3943
|
+
},
|
|
3944
|
+
devDependencies: {
|
|
3945
|
+
"@types/node": "^24.1.0",
|
|
3946
|
+
tsup: "^8.5.0",
|
|
3947
|
+
typescript: "^5.8.3",
|
|
3948
|
+
vitest: "^4.0.18"
|
|
3949
|
+
},
|
|
3950
|
+
scripts: {
|
|
3951
|
+
prepublishOnly: "node scripts/verify-integrity-sync.js",
|
|
3952
|
+
build: "tsup",
|
|
3953
|
+
dev: "tsup --watch",
|
|
3954
|
+
start: "node dist/server.js",
|
|
3955
|
+
test: "vitest run",
|
|
3956
|
+
"test:coverage": "vitest run --coverage"
|
|
3957
|
+
},
|
|
3958
|
+
name: "@stackwright-pro/mcp",
|
|
3959
|
+
version: "0.2.0-alpha.32",
|
|
3960
|
+
description: "MCP tools for Stackwright Pro - Data Explorer, Security, ISR, and Dashboard generation",
|
|
3961
|
+
license: "PROPRIETARY",
|
|
3962
|
+
main: "./dist/server.js",
|
|
3963
|
+
bin: {
|
|
3964
|
+
"stackwright-pro-mcp": "./dist/server.js"
|
|
3965
|
+
},
|
|
3966
|
+
module: "./dist/server.mjs",
|
|
3967
|
+
types: "./dist/server.d.ts",
|
|
3968
|
+
exports: {
|
|
3969
|
+
".": {
|
|
3970
|
+
types: "./dist/server.d.ts",
|
|
3971
|
+
import: "./dist/server.mjs",
|
|
3972
|
+
require: "./dist/server.js"
|
|
3973
|
+
},
|
|
3974
|
+
"./integrity": {
|
|
3975
|
+
types: "./dist/integrity.d.ts",
|
|
3976
|
+
import: "./dist/integrity.mjs",
|
|
3977
|
+
require: "./dist/integrity.js"
|
|
3978
|
+
},
|
|
3979
|
+
"./type-schemas": {
|
|
3980
|
+
types: "./dist/tools/type-schemas.d.ts",
|
|
3981
|
+
import: "./dist/tools/type-schemas.mjs",
|
|
3982
|
+
require: "./dist/tools/type-schemas.js"
|
|
1237
3983
|
}
|
|
1238
3984
|
},
|
|
1239
3985
|
files: [
|
|
@@ -1255,6 +4001,14 @@ registerIsrTools(server);
|
|
|
1255
4001
|
registerDashboardTools(server);
|
|
1256
4002
|
registerClarificationTools(server);
|
|
1257
4003
|
registerPackageTools(server);
|
|
4004
|
+
registerQuestionTools(server);
|
|
4005
|
+
registerOrchestrationTools(server);
|
|
4006
|
+
registerPipelineTools(server);
|
|
4007
|
+
registerSafeWriteTools(server);
|
|
4008
|
+
registerAuthTools(server);
|
|
4009
|
+
registerIntegrityTools(server);
|
|
4010
|
+
registerDomainTools(server);
|
|
4011
|
+
registerTypeSchemasTool(server);
|
|
1258
4012
|
async function main() {
|
|
1259
4013
|
const transport = new import_stdio.StdioServerTransport();
|
|
1260
4014
|
await server.connect(transport);
|