@stackwright-pro/mcp 0.2.0-alpha.97 → 0.2.0-alpha.98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/tools/pipeline.ts","../src/coerce.ts","../src/artifact-signing.ts","../src/tools/get-schema.ts","../src/tools/validate-yaml-fragment.ts"],"sourcesContent":["/**\n * Pipeline State & Artifact Management Tools — \"Sinks Not Pipes\"\n *\n * The filesystem IS the state machine. Every tool reads/writes to `.stackwright/`.\n * Specialists produce artifacts → validated here. Retry logic lives HERE.\n */\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\nimport { readFileSync, writeFileSync, existsSync, mkdirSync, lstatSync } from 'fs';\nimport { join } from 'path';\nimport { parseLLMQuestionsResponse } from '../question-adapter.js';\nimport { boolCoerce, jsonCoerce } from '../coerce.js';\nimport { createHash } from 'crypto';\nimport {\n initPipelineKeys,\n loadPipelineKeys,\n signArtifact,\n verifyArtifact,\n getArtifactSignature,\n saveArtifactSignature,\n emitSignatureAuditEvent,\n loadSignatureManifest,\n} from '../artifact-signing.js';\nimport { SAFE_PHASE } from '../validation.js';\n\n// ─── Canonical pro type schemas for artifact validation ─────────────────────\nimport { WorkflowFileSchema, authConfigSchema } from '@stackwright-pro/types';\nimport { buildSchemaReferenceBlock } from './get-schema.js';\nimport { loadPipelineGraph } from './pipeline-graph.js';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/**\n * Canonical phase execution order — must remain a valid topological sort of\n * the otter pipeline declarations. Validated at server startup by server.ts.\n *\n * Wave decomposition (derived from loadPipelineGraph()):\n * Wave 1: designer, api (no deps)\n * Wave 2: theme, auth, data (designer or api only)\n * Wave 3: scaffold, workflow, geo (theme / theme+api / data)\n * Wave 4: services (api + workflow)\n * Wave 5: pages, dashboard (all above)\n * Wave 6: polish (pages + dashboard + workflow + auth)\n * Wave 7: qa (depends on polish + transitively everything)\n *\n * Key change from the old hardcoded PHASE_DEPENDENCIES:\n * auth: was wave 4 (after pages/dashboard/workflow/geo); now wave 2 (needs designer only)\n * workflow: was wave 1 (no deps); now wave 3 (needs theme + api)\n */\nexport const PHASE_ORDER = [\n 'designer',\n 'theme',\n 'scaffold', // generates app/ directory from config\n 'api',\n 'data',\n 'auth', // moved earlier — only depends on design-language.json (designer)\n 'geo',\n 'workflow',\n 'services',\n 'pages',\n 'dashboard',\n 'polish',\n 'qa',\n] as const;\nexport type Phase = (typeof PHASE_ORDER)[number];\n\n/** Maps phase → expected artifact filename */\nexport const PHASE_ARTIFACT: Record<Phase, string> = {\n designer: 'design-language.json',\n theme: 'theme-tokens.json',\n scaffold: 'scaffold-manifest.json',\n api: 'api-config.json',\n auth: 'auth-config.json',\n data: 'data-config.json',\n pages: 'pages-manifest.json',\n dashboard: 'dashboard-manifest.json',\n workflow: 'workflow-config.json',\n services: 'services-config.json',\n polish: 'polish-manifest.json',\n geo: 'geo-manifest.json',\n qa: 'qa-findings.json',\n};\n\n/** Maps phase → otter agent name */\nconst PHASE_TO_OTTER: Record<Phase, string> = {\n designer: 'stackwright-pro-designer-otter',\n theme: 'stackwright-pro-theme-otter',\n scaffold: 'stackwright-pro-scaffold-otter',\n api: 'stackwright-pro-api-otter',\n auth: 'stackwright-pro-auth-otter',\n data: 'stackwright-pro-data-otter',\n pages: 'stackwright-pro-page-otter',\n dashboard: 'stackwright-pro-dashboard-otter',\n workflow: 'stackwright-pro-form-wizard-otter',\n services: 'stackwright-services-otter',\n polish: 'stackwright-pro-polish-otter',\n geo: 'stackwright-pro-geo-otter',\n qa: 'stackwright-pro-qa-otter',\n};\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface PhaseStatus {\n questionsCollected: boolean;\n answered: boolean;\n executed: boolean;\n artifactWritten: boolean;\n retryCount: number;\n}\n\nexport interface PipelineState {\n version: '1.0';\n currentPhase: string;\n status: 'setup' | 'questions' | 'execution' | 'done';\n phases: Record<string, PhaseStatus>;\n startedAt: string;\n updatedAt: string;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction isValidPhase(phase: string): phase is Phase {\n return (PHASE_ORDER as readonly string[]).includes(phase);\n}\n\nfunction defaultPhaseStatus(): PhaseStatus {\n return {\n questionsCollected: false,\n answered: false,\n executed: false,\n artifactWritten: false,\n retryCount: 0,\n };\n}\n\nfunction createDefaultState(): PipelineState {\n const now = new Date().toISOString();\n const phases: Record<string, PhaseStatus> = {};\n for (const p of PHASE_ORDER) {\n phases[p] = defaultPhaseStatus();\n }\n return {\n version: '1.0',\n currentPhase: PHASE_ORDER[0],\n status: 'setup',\n phases,\n startedAt: now,\n updatedAt: now,\n };\n}\n\nfunction statePath(cwd: string): string {\n return join(cwd, '.stackwright', 'pipeline-state.json');\n}\n\nfunction readState(cwd: string): PipelineState {\n const p = statePath(cwd);\n if (!existsSync(p)) return createDefaultState();\n const raw = JSON.parse(safeReadSync(p));\n // Minimal schema validation — reject corrupted state files\n if (typeof raw !== 'object' || raw === null || raw.version !== '1.0') {\n return createDefaultState();\n }\n return raw as PipelineState;\n}\n\n/**\n * Safely write to a path, rejecting symlinks.\n * Used by all pipeline write operations.\n */\nfunction safeWriteSync(filePath: string, content: string): void {\n if (existsSync(filePath)) {\n const stat = lstatSync(filePath);\n if (stat.isSymbolicLink()) {\n throw new Error(`Refusing to write to symlink: ${filePath}`);\n }\n }\n writeFileSync(filePath, content);\n}\n\n/**\n * Safely read from a path, rejecting symlinks.\n * Used by all pipeline read operations to prevent TOCTOU-based symlink attacks.\n */\nfunction safeReadSync(filePath: string): string {\n if (existsSync(filePath)) {\n const stat = lstatSync(filePath);\n if (stat.isSymbolicLink()) {\n throw new Error(`Refusing to read symlink: ${filePath}`);\n }\n }\n return readFileSync(filePath, 'utf-8');\n}\n\nfunction writeState(cwd: string, state: PipelineState): void {\n const dir = join(cwd, '.stackwright');\n mkdirSync(dir, { recursive: true });\n state.updatedAt = new Date().toISOString();\n safeWriteSync(statePath(cwd), JSON.stringify(state, null, 2) + '\\n');\n}\n\n/** Extract JSON from messy LLM output — handles markdown fences, leading prose */\nfunction extractJsonFromResponse(text: string): unknown {\n let cleaned = text;\n // Strip markdown code fences (```json ... ``` or ``` ... ```)\n cleaned = cleaned.replace(/```(?:json)?\\s*/gi, '');\n cleaned = cleaned.replace(/```\\s*$/gm, '');\n\n // Find the outermost JSON object\n const firstBrace = cleaned.indexOf('{');\n if (firstBrace === -1) throw new Error('No JSON object found in response');\n cleaned = cleaned.substring(firstBrace);\n\n const lastBrace = cleaned.lastIndexOf('}');\n if (lastBrace === -1) throw new Error('Unclosed JSON object in response');\n cleaned = cleaned.substring(0, lastBrace + 1);\n\n // Fix common LLM JSON quirks\n cleaned = cleaned.replace(/,(\\s*[}\\]])/g, '$1'); // trailing commas\n return JSON.parse(cleaned);\n}\n\n// ---------------------------------------------------------------------------\n// Handler functions (exported for direct testing — no MCP server needed)\n// ---------------------------------------------------------------------------\n\nexport function handleGetPipelineState(_cwd?: string): { text: string; isError: boolean } {\n const cwd = _cwd ?? process.cwd();\n try {\n const state = readState(cwd);\n\n // Auto-initialize signing keys on first access (lazy init)\n const keyPath = join(cwd, '.stackwright', 'pipeline-keys.json');\n if (!existsSync(keyPath)) {\n try {\n const { fingerprint } = initPipelineKeys(cwd);\n // Record key fingerprint in pipeline state for audit trail\n (state as unknown as Record<string, unknown>)['signingKeyFingerprint'] = fingerprint;\n writeState(cwd, state);\n } catch {\n // Non-blocking — signing is defense-in-depth, not a hard gate at init\n }\n }\n\n return { text: JSON.stringify(state), isError: false };\n } catch (err) {\n return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };\n }\n}\n\nexport interface BatchStateUpdate {\n phase: string;\n field: 'questionsCollected' | 'answered' | 'executed' | 'artifactWritten';\n value: boolean;\n}\n\nexport interface SetPipelineStateInput {\n phase?: string;\n field?: 'questionsCollected' | 'answered' | 'executed' | 'artifactWritten';\n value?: boolean;\n status?: 'setup' | 'questions' | 'execution' | 'done';\n incrementRetry?: boolean;\n updates?: BatchStateUpdate[];\n _cwd?: string;\n}\n\nexport function handleSetPipelineState(input: SetPipelineStateInput): {\n text: string;\n isError: boolean;\n} {\n const cwd = input._cwd ?? process.cwd();\n\n // Validate phase name if provided\n if (input.phase && !isValidPhase(input.phase)) {\n return {\n text: JSON.stringify({\n error: true,\n message: `Invalid phase: ${input.phase}. Valid phases are: ${PHASE_ORDER.join(', ')}`,\n }),\n isError: true,\n };\n }\n\n // Validate field name if provided\n const VALID_FIELDS = ['questionsCollected', 'answered', 'executed', 'artifactWritten'] as const;\n if (input.field && !VALID_FIELDS.includes(input.field as (typeof VALID_FIELDS)[number])) {\n return {\n text: JSON.stringify({\n error: true,\n message: `Invalid field: ${input.field}. Valid fields are: ${VALID_FIELDS.join(', ')}`,\n }),\n isError: true,\n };\n }\n\n // Validate batch updates before touching state\n if (input.updates && input.updates.length > 0) {\n for (const update of input.updates) {\n if (!isValidPhase(update.phase)) {\n return {\n text: JSON.stringify({\n error: true,\n message: `Invalid phase in batch update: ${update.phase}. Valid phases are: ${PHASE_ORDER.join(', ')}`,\n }),\n isError: true,\n };\n }\n if (!VALID_FIELDS.includes(update.field as (typeof VALID_FIELDS)[number])) {\n return {\n text: JSON.stringify({\n error: true,\n message: `Invalid field in batch update: ${update.field}. Valid fields are: ${VALID_FIELDS.join(', ')}`,\n }),\n isError: true,\n };\n }\n }\n }\n\n try {\n const state = readState(cwd);\n\n // Update top-level status if provided\n if (input.status) {\n state.status = input.status;\n }\n\n // Update phase-level fields\n if (input.phase) {\n const phase = input.phase;\n if (!state.phases[phase]) {\n state.phases[phase] = defaultPhaseStatus();\n }\n const phaseState = state.phases[phase];\n\n if (input.field && input.value !== undefined) {\n phaseState[input.field] = input.value;\n }\n\n if (input.incrementRetry) {\n phaseState.retryCount += 1;\n }\n\n state.currentPhase = phase;\n }\n\n // Apply batch updates in the same read→modify→write cycle\n if (input.updates && input.updates.length > 0) {\n for (const update of input.updates) {\n if (!state.phases[update.phase]) {\n state.phases[update.phase] = defaultPhaseStatus();\n }\n const batchPhaseState = state.phases[update.phase]!;\n batchPhaseState[update.field] = update.value;\n state.currentPhase = update.phase;\n }\n }\n\n writeState(cwd, state);\n return { text: JSON.stringify(state), isError: false };\n } catch (err) {\n return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };\n }\n}\n\nexport function handleCheckExecutionReady(\n _cwd?: string,\n phase?: string\n): { text: string; isError: boolean } {\n const cwd = _cwd ?? process.cwd();\n\n if (phase) {\n if (!isValidPhase(phase)) {\n return {\n text: JSON.stringify({\n error: true,\n message: `Invalid phase: ${phase}. Valid phases are: ${PHASE_ORDER.join(', ')}`,\n }),\n isError: true,\n };\n }\n const answerFile = join(cwd, '.stackwright', 'answers', `${phase}.json`);\n if (!existsSync(answerFile)) {\n return {\n text: JSON.stringify({ ready: false, phase, reason: 'Answer file not found' }),\n isError: false,\n };\n }\n try {\n const raw = safeReadSync(answerFile);\n const parsed = JSON.parse(raw) as Record<string, unknown>;\n if (\n typeof parsed['version'] !== 'string' ||\n typeof parsed['phase'] !== 'string' ||\n typeof parsed['answers'] !== 'object' ||\n parsed['answers'] === null\n ) {\n return {\n text: JSON.stringify({ ready: false, phase, reason: 'Answer file is malformed' }),\n isError: false,\n };\n }\n return { text: JSON.stringify({ ready: true, phase }), isError: false };\n } catch {\n return {\n text: JSON.stringify({ ready: false, phase, reason: 'Answer file could not be parsed' }),\n isError: false,\n };\n }\n }\n\n try {\n const answersDir = join(cwd, '.stackwright', 'answers');\n const answeredPhases: string[] = [];\n const missingPhases: string[] = [];\n\n for (const phase of PHASE_ORDER) {\n const answerFile = join(answersDir, `${phase}.json`);\n if (existsSync(answerFile)) {\n // Validate: file must be parseable JSON with required fields\n try {\n const raw = safeReadSync(answerFile);\n const parsed = JSON.parse(raw) as Record<string, unknown>;\n\n // Must have version, phase, and answers keys\n if (\n typeof parsed['version'] !== 'string' ||\n typeof parsed['phase'] !== 'string' ||\n typeof parsed['answers'] !== 'object' ||\n parsed['answers'] === null\n ) {\n // File exists but is malformed — treat as missing\n missingPhases.push(phase);\n continue;\n }\n\n answeredPhases.push(phase);\n } catch {\n // Can't parse — treat as missing\n missingPhases.push(phase);\n }\n } else {\n missingPhases.push(phase);\n }\n }\n\n return {\n text: JSON.stringify({\n ready: missingPhases.length === 0,\n answeredPhases,\n missingPhases,\n totalPhases: PHASE_ORDER.length,\n }),\n isError: false,\n };\n } catch (err) {\n return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };\n }\n}\n\n/**\n * Return phases whose dependencies are all satisfied (executed === true in\n * pipeline state). The foreman calls this in a loop to discover which phases\n * can run next — enabling wave-based execution instead of strict serial order.\n *\n * Wave decomposition (derived from otter pipeline declarations — see PHASE_ORDER):\n * Wave 1: designer, api\n * Wave 2: theme, auth, data\n * Wave 3: scaffold, workflow, geo\n * Wave 4: services\n * Wave 5: pages, dashboard\n * Wave 6: polish\n */\nexport function handleGetReadyPhases(_cwd?: string): { text: string; isError: boolean } {\n const cwd = _cwd ?? process.cwd();\n try {\n const state = readState(cwd);\n const graph = loadPipelineGraph();\n\n const completed: string[] = [];\n const ready: string[] = [];\n const blocked: string[] = [];\n\n for (const phase of PHASE_ORDER) {\n const ps = state.phases[phase];\n if (ps?.executed) {\n completed.push(phase);\n continue;\n }\n // Phase is ready if every dependency is executed\n const deps = graph.dependencies[phase] ?? [];\n const unmetDeps = deps.filter((dep) => !state.phases[dep]?.executed);\n if (unmetDeps.length === 0) {\n ready.push(phase);\n } else {\n blocked.push(phase);\n }\n }\n\n return {\n text: JSON.stringify({\n readyPhases: ready,\n completedPhases: completed,\n blockedPhases: blocked,\n waveSize: ready.length,\n allComplete: ready.length === 0 && blocked.length === 0,\n }),\n isError: false,\n };\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n return { text: JSON.stringify({ error: true, message }), isError: true };\n }\n}\n\nexport function handleListArtifacts(_cwd?: string): { text: string; isError: boolean } {\n const cwd = _cwd ?? process.cwd();\n try {\n const artifactsDir = join(cwd, '.stackwright', 'artifacts');\n\n // Load signature manifest (non-blocking if missing)\n let manifest: ReturnType<typeof loadSignatureManifest> | null = null;\n try {\n manifest = loadSignatureManifest(cwd);\n } catch {\n // No signature manifest — report unsigned\n }\n\n const artifacts: Array<{\n phase: string;\n expectedFile: string;\n exists: boolean;\n path: string;\n signed: boolean;\n signatureValid: boolean | null;\n }> = [];\n let completedCount = 0;\n\n for (const phase of PHASE_ORDER) {\n const expectedFile = PHASE_ARTIFACT[phase];\n const fullPath = join(artifactsDir, expectedFile);\n const exists = existsSync(fullPath);\n if (exists) completedCount++;\n\n // Check signature status\n let signed = false;\n let signatureValid: boolean | null = null;\n\n if (exists && manifest) {\n const entry = manifest.signatures[expectedFile];\n if (entry) {\n signed = true;\n // Verify if keys are available\n try {\n const rawBytes = Buffer.from(safeReadSync(fullPath), 'utf-8');\n const { publicKey } = loadPipelineKeys(cwd);\n signatureValid = verifyArtifact(rawBytes, entry, publicKey);\n } catch {\n signatureValid = null; // Can't verify — keys unavailable\n }\n }\n }\n\n artifacts.push({ phase, expectedFile, exists, path: fullPath, signed, signatureValid });\n }\n\n return {\n text: JSON.stringify({ artifacts, completedCount, totalCount: PHASE_ORDER.length }),\n isError: false,\n };\n } catch (err) {\n return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };\n }\n}\n\nexport interface WritePhaseQuestionsInput {\n phase: string;\n responseText: string;\n _cwd?: string;\n}\n\nexport function handleWritePhaseQuestions(input: WritePhaseQuestionsInput): {\n text: string;\n isError: boolean;\n} {\n const cwd = input._cwd ?? process.cwd();\n const { phase, responseText } = input;\n\n if (!isValidPhase(phase)) {\n return {\n text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),\n isError: true,\n };\n }\n\n try {\n const questions = parseLLMQuestionsResponse(responseText);\n\n // Extract requiredPackages if present in the raw response\n let requiredPackages: {\n dependencies: Record<string, string>;\n devPackages: Record<string, string>;\n } = {\n dependencies: {},\n devPackages: {},\n };\n try {\n const fullParsed = extractJsonFromResponse(responseText) as Record<string, unknown>;\n if (fullParsed.requiredPackages && typeof fullParsed.requiredPackages === 'object') {\n const rp = fullParsed.requiredPackages as Record<string, unknown>;\n requiredPackages = {\n dependencies: (rp.dependencies as Record<string, string>) ?? {},\n devPackages: (rp.devPackages as Record<string, string>) ?? {},\n };\n }\n } catch {\n // extractJsonFromResponse may fail if the response is a bare array — that's fine\n }\n\n const questionsDir = join(cwd, '.stackwright', 'questions');\n mkdirSync(questionsDir, { recursive: true });\n const filePath = join(questionsDir, `${phase}.json`);\n\n const payload = {\n version: '1.0',\n phase,\n otter: PHASE_TO_OTTER[phase],\n collectedAt: new Date().toISOString(),\n questions,\n requiredPackages,\n };\n\n safeWriteSync(filePath, JSON.stringify(payload, null, 2) + '\\n');\n\n // Update pipeline state\n const state = readState(cwd);\n if (!state.phases[phase]) state.phases[phase] = defaultPhaseStatus();\n const ps = state.phases[phase]!; // guaranteed by line above\n ps.questionsCollected = true;\n writeState(cwd, state);\n\n return {\n text: JSON.stringify({\n success: true,\n phase,\n questionCount: questions.length,\n requiredPackages,\n path: filePath,\n }),\n isError: false,\n };\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n return { text: JSON.stringify({ error: true, phase, message }), isError: true };\n }\n}\n\nexport interface BuildSpecialistPromptInput {\n phase: string;\n _cwd?: string;\n}\n\nexport function handleBuildSpecialistPrompt(input: BuildSpecialistPromptInput): {\n text: string;\n isError: boolean;\n} {\n const cwd = input._cwd ?? process.cwd();\n const { phase } = input;\n\n if (!isValidPhase(phase)) {\n return {\n text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),\n isError: true,\n };\n }\n\n try {\n // 1. Read answers for this phase\n const answersPath = join(cwd, '.stackwright', 'answers', `${phase}.json`);\n let answers: unknown = {};\n if (existsSync(answersPath)) {\n answers = JSON.parse(safeReadSync(answersPath));\n }\n\n // 1b. Read build context (optional — non-blocking if missing or malformed)\n let buildContextText = '';\n const buildContextPath = join(cwd, '.stackwright', 'build-context.json');\n if (existsSync(buildContextPath)) {\n try {\n const bcRaw = JSON.parse(safeReadSync(buildContextPath)) as { buildContext?: string };\n if (typeof bcRaw.buildContext === 'string' && bcRaw.buildContext.trim().length > 0) {\n buildContextText = bcRaw.buildContext.trim();\n }\n } catch {\n // Non-blocking — missing or malformed build context does not block execution\n }\n }\n\n // 2. Read upstream dependency artifacts\n const graph = loadPipelineGraph();\n const deps = graph.dependencies[phase] ?? [];\n const artifactSections: string[] = [];\n const missingDependencies: string[] = [];\n\n for (const dep of deps) {\n const artifactFile = PHASE_ARTIFACT[dep as Phase];\n const artifactPath = join(cwd, '.stackwright', 'artifacts', artifactFile);\n if (existsSync(artifactPath)) {\n const rawContent = safeReadSync(artifactPath);\n const rawBytes = Buffer.from(rawContent, 'utf-8');\n const content = JSON.parse(rawContent) as Record<string, unknown>;\n\n // ── Cryptographic signature verification (primary integrity check) ──\n // If signing keys exist, verify the ECDSA P-384 signature before trusting\n // the artifact. Falls back to generatedBy check if unsigned (backwards compat).\n let signatureVerified = false;\n let signatureAvailable = false;\n\n try {\n const sig = getArtifactSignature(cwd, artifactFile);\n if (sig) {\n signatureAvailable = true;\n const { publicKey } = loadPipelineKeys(cwd);\n signatureVerified = verifyArtifact(rawBytes, sig, publicKey);\n\n if (!signatureVerified) {\n // Emit audit event for SOC2/FedRAMP compliance\n const actualDigest = createHash('sha384').update(rawBytes).digest('hex');\n emitSignatureAuditEvent({\n artifactFilename: artifactFile,\n expectedDigest: sig.digest,\n actualDigest,\n phase: dep,\n timestamp: new Date().toISOString(),\n source: 'stackwright_pro_build_specialist_prompt',\n });\n\n missingDependencies.push(dep);\n artifactSections.push(\n `[${artifactFile}]:\\n(integrity check failed: ECDSA-P384 signature verification failed — artifact may have been tampered with)`\n );\n continue;\n }\n }\n } catch {\n // Key loading failed — fall through to generatedBy check\n }\n\n // ── Secondary check: generatedBy field cross-validation ──\n // Even with valid signatures, verify the generatedBy field matches expectations.\n // Without signatures (unsigned pipeline), this is the primary check.\n const expectedOtter = PHASE_TO_OTTER[dep as Phase];\n const artifactOtter = content['generatedBy'] as string | undefined;\n const normalizedOtter = artifactOtter?.replace(/-[a-f0-9]{6}$/, '');\n\n if (!artifactOtter) {\n missingDependencies.push(dep);\n artifactSections.push(\n `[${artifactFile}]:\\n(integrity check failed: missing generatedBy field)`\n );\n } else if (normalizedOtter !== expectedOtter) {\n missingDependencies.push(dep);\n artifactSections.push(\n `[${artifactFile}]:\\n(integrity check failed: artifact claims generatedBy=\"${artifactOtter}\" but expected=\"${expectedOtter}\")`\n );\n } else {\n const sigStatus = signatureAvailable\n ? signatureVerified\n ? ' [signature verified]'\n : ' [signature check skipped]'\n : ' [unsigned]';\n artifactSections.push(\n `[${artifactFile}]${sigStatus}:\\n${JSON.stringify(content, null, 2)}`\n );\n }\n } else {\n missingDependencies.push(dep);\n artifactSections.push(`[${artifactFile}]:\\n(not yet available)`);\n }\n }\n\n // 3. Assemble the prompt\n const parts: string[] = [];\n\n if (buildContextText) {\n parts.push('BUILD_CONTEXT:', buildContextText, '');\n }\n\n // For auth phase: inject devOnly flag from init-context so the auth otter\n // always passes it to configure_auth — prevents prebuild crash (env var refs in dev mode)\n if (phase === 'auth') {\n const initContextPath = join(cwd, '.stackwright', 'init-context.json');\n if (existsSync(initContextPath)) {\n try {\n const initContext = JSON.parse(safeReadSync(initContextPath));\n if (initContext.devOnly === true || initContext.nonInteractive === true) {\n (answers as Record<string, unknown>)['devOnly'] = true;\n }\n } catch {\n // Non-blocking — auth otter can still infer from BUILD_CONTEXT\n }\n }\n }\n\n parts.push('ANSWERS:', JSON.stringify(answers, null, 2));\n\n if (artifactSections.length > 0) {\n parts.push('', 'UPSTREAM ARTIFACTS:', '', ...artifactSections);\n }\n\n // Inject the canonical artifact schema so specialists always build against\n // the current contract — not hardcoded examples in their JSON files.\n const artifactSchema = PHASE_ARTIFACT_SCHEMA[phase];\n parts.push('', 'REQUIRED_ARTIFACT_SCHEMA:');\n parts.push(artifactSchema);\n\n // Inject canonical field-name schema reference for phases that produce\n // YAML with strict schemas (workflow, auth, pages/navigation). This closes\n // the schema-prompt impedance mismatch identified across 5 postmortems —\n // specialists receive the actual Zod schema, not a prose copy that can drift.\n const schemaRef = buildSchemaReferenceBlock(phase);\n if (schemaRef) {\n parts.push('', schemaRef);\n }\n\n parts.push('', 'Execute using these answers and the upstream artifacts provided.');\n\n const prompt = parts.join('\\n');\n const dependenciesSatisfied = missingDependencies.length === 0;\n\n return {\n text: JSON.stringify({\n otterName: PHASE_TO_OTTER[phase],\n phase,\n prompt,\n dependenciesSatisfied,\n missingDependencies,\n }),\n isError: false,\n };\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n return { text: JSON.stringify({ error: true, phase, message }), isError: true };\n }\n}\n\nexport interface ValidateArtifactInput {\n phase: string;\n responseText?: string;\n artifact?: Record<string, unknown>;\n _cwd?: string;\n}\n\nconst OFF_SCRIPT_PATTERNS: Array<{ pattern: RegExp; label: string }> = [\n {\n pattern: /```(?:ts|tsx|js|jsx|python|bash|sh|sql|ruby|go|rust|java|csharp|c\\+\\+)\\b/,\n label: 'code fence',\n },\n { pattern: /\\bimport\\s+[\\w{]/, label: 'import statement' },\n { pattern: /\\bexport\\s+(?:const|function|default|class)\\b/, label: 'export statement' },\n { pattern: /\\brequire\\s*\\(/, label: 'require() call' },\n { pattern: /\\beval\\s*\\(/, label: 'eval() call' },\n { pattern: /^#!/m, label: 'shebang' },\n { pattern: /<script[\\s>]/i, label: 'script tag' },\n { pattern: /\\.(ts|tsx|js|jsx)\\b.*\\bfile\\b/i, label: 'code file reference' },\n];\n\ninterface ValidArtifactResult {\n valid: true;\n phase: string;\n artifactPath: string;\n summary: string;\n}\n\ninterface InvalidArtifactResult {\n valid: false;\n phase: string;\n violation: 'off-script' | 'invalid-json' | 'missing-fields' | 'schema-mismatch' | 'rbac-theatre';\n retryPrompt: string;\n}\n\nconst PHASE_REQUIRED_KEYS: Record<Phase, string[]> = {\n designer: ['designLanguage', 'themeTokenSeeds'],\n theme: ['tokens'],\n scaffold: ['version', 'generatedBy', 'appRouterFiles'],\n api: ['entities', 'version', 'generatedBy'],\n auth: ['version', 'generatedBy'],\n data: ['version', 'generatedBy', 'strategy', 'collections'],\n pages: ['version', 'generatedBy'],\n dashboard: ['version', 'generatedBy'],\n workflow: ['version', 'generatedBy'],\n services: ['version', 'generatedBy', 'flows'],\n polish: ['version', 'generatedBy'],\n geo: ['version', 'generatedBy', 'geoCollections'],\n // qa: skipped=true path only needs version+generatedBy+skipped;\n // skipped=false path also needs summary+findings — Zod validator enforces the conditional.\n qa: ['version', 'generatedBy', 'skipped'],\n};\n\n/**\n * Canonical artifact schema examples for each phase.\n *\n * SINGLE SOURCE OF TRUTH — injected into specialist prompts at invocation time\n * via handleBuildSpecialistPrompt. Both validate_artifact (enforcement) and\n * build_specialist_prompt (guidance) derive from the same constant here.\n *\n * Schema changes happen in THIS file only. Zero drift possible.\n */\nconst PHASE_ARTIFACT_SCHEMA: Record<Phase, string> = {\n designer: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-designer-otter',\n application: {\n type: '<operational|data-explorer|admin|logistics|general>',\n environment: '<workstation|field|control-room|mixed>',\n density: '<compact|balanced|spacious>',\n accessibility: '<wcag-aa|section-508|none>',\n colorScheme: '<light|dark|both>',\n },\n designLanguage: {\n rationale: '<design rationale>',\n spacingScale: { base: 8, scale: [0, 4, 8, 16, 24, 32, 48, 64] },\n colorSemantics: { primary: '#1a365d', accent: '#e53e3e' },\n typography: {\n dataFont: 'Inter',\n headingFont: 'Inter',\n monoFont: 'monospace',\n dataSizePx: 12,\n bodySizePx: 14,\n },\n contrastRatio: '4.5',\n borderRadius: '4',\n shadowElevation: 'standard',\n },\n themeTokenSeeds: {\n light: {\n background: '#ffffff',\n foreground: '#1a1a1a',\n primary: '#1a365d',\n surface: '#f7f7f7',\n border: '#e2e8f0',\n },\n dark: {\n background: '#1a1a1a',\n foreground: '#ffffff',\n primary: '#90cdf4',\n surface: '#2d2d2d',\n border: '#4a5568',\n },\n },\n conformsTo: null,\n operationalNotes: [],\n },\n null,\n 2\n ),\n\n theme: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-theme-otter',\n componentLibrary: 'shadcn',\n colorScheme: '<light|dark|both>',\n tokens: {\n colors: { 'primary-500': '#1a365d', background: '#ffffff' },\n spacing: { 'spacing-1': '8px', 'spacing-2': '16px' },\n typography: { 'font-data': 'Inter', 'text-sm': '12px' },\n shape: { 'radius-sm': '4px', 'radius-md': '8px' },\n shadows: { 'shadow-sm': '0 1px 2px rgba(0,0,0,0.08)' },\n },\n cssVariables: {\n '--background': '0 0% 100%',\n '--foreground': '222.2 84% 4.9%',\n '--primary': '222.2 47.4% 11.2%',\n '--primary-foreground': '210 40% 98%',\n '--surface': '210 40% 98%',\n '--border': '214.3 31.8% 91.4%',\n },\n dark: { '--background': '222.2 84% 4.9%', '--foreground': '210 40% 98%' },\n },\n null,\n 2\n ),\n\n scaffold: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-scaffold-otter',\n // REQUIRED: appRouterFiles is a required key — NOT 'files' (see swp-k3mb)\n appRouterFiles: [\n 'app/layout.tsx',\n 'app/_components/page-client.tsx',\n 'app/page.tsx',\n 'app/[...slug]/page.tsx',\n 'app/_components/providers.tsx',\n 'app/not-found.tsx',\n ],\n title: '<title from stackwright.yml>',\n hasCollectionEndpoints: false,\n hasAuthConfig: true,\n },\n null,\n 2\n ),\n\n api: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-api-otter',\n entities: [\n {\n name: 'Shipment',\n endpoint: '/shipments',\n method: 'GET',\n revalidate: 60,\n mutationType: null,\n },\n ],\n skipped: [\n {\n spec: 'ais-message-models.yaml',\n format: 'asyncapi',\n reason: 'AsyncAPI spec — WebSocket/event integration required (no REST endpoints)',\n },\n ],\n warnings: [\n 'Spec contains external $ref URLs — recommend bundle: true in stackwright.yml integration config',\n ],\n auth: { type: 'bearer', header: 'Authorization', envVar: 'API_TOKEN' },\n baseUrl: 'https://api.example.mil/v2',\n specPath: './specs/api.yaml',\n },\n null,\n 2\n ),\n\n data: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-data-otter',\n strategy: '<pulse-fast|isr-fast|isr-standard|isr-slow>',\n pulseMode: false,\n collections: [{ name: 'equipment', revalidate: 60, pulse: false }],\n endpoints: { included: ['/equipment/**'], excluded: ['/admin/**'] },\n requiredPackages: { dependencies: {}, devPackages: {} },\n },\n null,\n 2\n ),\n\n geo: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-geo-otter',\n geoCollections: [\n {\n collection: 'vessels',\n latField: 'latitude',\n lngField: 'longitude',\n labelField: 'vesselName',\n colorField: 'navigationStatus',\n },\n ],\n pages: [\n {\n slug: 'fleet-tracker',\n type: '<tracker|zone|route|combined>',\n collections: ['vessels'],\n hasLayers: false,\n },\n ],\n },\n null,\n 2\n ),\n\n workflow: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-form-wizard-otter',\n // Root key is `workflow` — NOT `workflowConfig` (see swp-k7cl)\n workflow: {\n id: 'procurement-approval',\n route: '/procurement',\n files: ['workflows/procurement-approval.yml'],\n serviceDependencies: ['service:workflow-state'],\n warnings: [],\n },\n },\n null,\n 2\n ),\n\n services: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-services-otter',\n flows: [\n {\n name: 'at-risk-patients',\n trigger: '<http|event|schedule|queue>',\n steps: 3,\n outputSpec: 'at-risk-patients.openapi.json',\n },\n ],\n workflows: [\n {\n name: 'evacuation-coordination',\n states: 4,\n transitions: 5,\n },\n ],\n openApiSpecs: ['at-risk-patients.openapi.json'],\n capabilitiesUsed: ['service.call', 'collection.join', 'collection.filter'],\n },\n null,\n 2\n ),\n\n pages: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-page-otter',\n pages: [\n {\n slug: 'catalog',\n type: 'collection_listing',\n collection: 'products',\n themeApplied: true,\n authRequired: false,\n },\n {\n slug: 'admin',\n type: 'protected',\n collection: null,\n themeApplied: true,\n authRequired: true,\n },\n ],\n },\n null,\n 2\n ),\n\n dashboard: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-dashboard-otter',\n pages: [\n {\n slug: 'dashboard',\n layout: '<grid|table|mixed>',\n collections: ['equipment', 'supplies'],\n mode: '<ISR|Pulse>',\n },\n ],\n },\n null,\n 2\n ),\n\n // type: 'pki' = CAC/DoD certificate auth | 'oidc' = enterprise SSO\n // For dev-only mock auth: use type: 'oidc' with devOnly: true (Zod strips devOnly — it's a convention only)\n auth: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-auth-otter',\n authConfig: {\n type: '<pki|oidc>',\n // OIDC-only fields (omit for pki):\n provider: '<azure_ad|okta|cognito|auth0|authentik|keycloak|custom>',\n discoveryUrl: '<IdP OIDC discovery URL>',\n clientId: '<OIDC client ID>',\n clientSecret: '<OIDC client secret>',\n rbacRoles: ['ADMIN', 'ANALYST'],\n rbacDefaultRole: 'ANALYST',\n protectedRoutes: ['/dashboard/:path*', '/procurement/:path*'],\n auditEnabled: true,\n auditRetentionDays: 90,\n },\n // devScripts is OPTIONAL — only present when devOnly: true was used\n // Polish otter and foreman MUST check devScripts.written before referencing scripts\n devScripts: {\n written: true,\n scripts: { 'dev:admin': 'MOCK_USER=admin next dev' },\n },\n },\n null,\n 2\n ),\n\n polish: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-polish-otter',\n landingPage: { slug: '', rewritten: true },\n navigation: { itemCount: 5, items: ['/dashboard', '/equipment', '/workflows'] },\n gettingStarted: '<rewritten|redirected|not-found>',\n scaffoldCleanup: {\n deleted: ['content/posts/getting-started.yaml'],\n rewritten: ['app/not-found.tsx'],\n skipped: [],\n },\n },\n null,\n 2\n ),\n\n qa: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-qa-otter',\n // skipped: true path — when dev server is unreachable at audit time\n // skipped: false path — full audit completed\n skipped: false,\n skipReason: null,\n wcagLevel: '<AA|AAA>',\n summary: {\n routesAudited: 3,\n serious: 0,\n moderate: 1,\n minor: 0,\n },\n findings: [\n {\n id: 'qa-001',\n route: '/dashboard',\n severity: '<serious|moderate|minor>',\n category: '<visual|a11y|design-contract|runtime>',\n finding: 'Human-readable description of what was observed',\n evidence: {\n screenshot: '.stackwright/qa/screenshots/dashboard-light.png',\n consoleErrors: [],\n axeViolations: [],\n },\n suggested_otters: ['stackwright-pro-theme-otter'],\n suggested_fix: 'Specific, concrete next step the repair otter should take',\n },\n ],\n },\n null,\n 2\n ),\n};\n\nexport function handleValidateArtifact(input: ValidateArtifactInput): {\n text: string;\n isError: boolean;\n} {\n const cwd = input._cwd ?? process.cwd();\n const { phase, responseText, artifact: directArtifact } = input;\n\n if (!isValidPhase(phase)) {\n return {\n text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),\n isError: true,\n };\n }\n\n let artifact: Record<string, unknown>;\n\n if (directArtifact) {\n // Direct artifact path (specialist calling with artifact object):\n // Skip off-script detection and JSON string parsing — already an object.\n artifact = directArtifact;\n } else {\n // Legacy responseText path (Foreman-mediated):\n const text = responseText ?? '';\n\n // 1. Off-script detection — check the RAW response before trying to parse\n for (const { pattern, label } of OFF_SCRIPT_PATTERNS) {\n if (pattern.test(text)) {\n const result: InvalidArtifactResult = {\n valid: false,\n phase,\n violation: 'off-script',\n retryPrompt: `You returned code output (detected: ${label}). Return ONLY a JSON artifact — no code, no files.`,\n };\n return { text: JSON.stringify(result), isError: false };\n }\n }\n\n // 2. JSON extraction\n try {\n artifact = extractJsonFromResponse(text) as Record<string, unknown>;\n } catch {\n const result: InvalidArtifactResult = {\n valid: false,\n phase,\n violation: 'invalid-json',\n retryPrompt:\n 'Your response did not contain valid JSON. Return a single JSON object with no surrounding text.',\n };\n return { text: JSON.stringify(result), isError: false };\n }\n }\n\n // 3. Required universal fields\n if (!artifact.version || !artifact.generatedBy) {\n const result: InvalidArtifactResult = {\n valid: false,\n phase,\n violation: 'missing-fields',\n retryPrompt:\n 'Your JSON artifact is missing required fields. Every artifact MUST include \"version\" and \"generatedBy\" at the top level.',\n };\n return { text: JSON.stringify(result), isError: false };\n }\n\n // 4. Phase-specific key checks\n const requiredKeys = PHASE_REQUIRED_KEYS[phase];\n const missingKeys = requiredKeys.filter((k) => !(k in artifact));\n if (missingKeys.length > 0) {\n const result: InvalidArtifactResult = {\n valid: false,\n phase,\n violation: 'schema-mismatch',\n retryPrompt: `Your ${phase} artifact is missing required keys: ${missingKeys.join(', ')}. Include them and try again.`,\n };\n return { text: JSON.stringify(result), isError: false };\n }\n\n // 5. Zod schema validation (where canonical schemas are available)\n // Provides richer error messages than key-presence checks alone.\n const PHASE_ZOD_VALIDATORS: Partial<\n Record<\n Phase,\n (artifact: Record<string, unknown>) => { success: boolean; error?: { message: string } }\n >\n > = {\n workflow: (artifact) => {\n // The workflow otter produces { version, generatedBy, workflow: { id, route, files, ... } }\n // Primary key: 'workflow'. Defensive: accept 'workflowConfig' as a deprecated alias.\n const workflowData = artifact['workflow'];\n if (!workflowData) {\n if (artifact['workflowConfig']) {\n // swp-k7cl: 'workflowConfig' is the old key name; warn and accept so the run\n // still succeeds while the otter prompt change propagates.\n console.warn(\n '[pipeline] DEPRECATED: workflow artifact uses \"workflowConfig\" key; rename to \"workflow\"'\n );\n return { success: true };\n }\n // No workflow/workflowConfig key — fine, PHASE_REQUIRED_KEYS only checks version/generatedBy\n return { success: true };\n }\n // Optionally deep-validate if the artifact contains the full workflow file content\n // (i.e., the otter embedded { workflow: { id, label, steps } } in the workflow key).\n // Skip if the value is plain metadata (no nested 'workflow' key).\n if (typeof workflowData === 'object' && workflowData !== null && 'workflow' in workflowData) {\n const result = WorkflowFileSchema.safeParse(workflowData);\n if (!result.success) {\n const issues = result.error.issues\n .slice(0, 3)\n .map((i) => `${i.path.join('.')}: ${i.message}`)\n .join('; ');\n return { success: false, error: { message: issues } };\n }\n }\n return { success: true };\n },\n auth: (artifact) => {\n // The auth otter produces { version, generatedBy, authConfig: { type: 'oidc'|'pki', ... } }\n const authConfig = artifact['authConfig'];\n if (!authConfig) return { success: true }; // skip if not present\n const result = authConfigSchema.safeParse(authConfig);\n if (!result.success) {\n const issues = result.error.issues\n .slice(0, 3)\n .map((i) => `${i.path.join('.')}: ${i.message}`)\n .join('; ');\n return { success: false, error: { message: issues } };\n }\n\n // swp-owu0 — defense-in-depth: reject RBAC theatre.\n // Fires when: >2 roles defined AND all protected routes share a single\n // requiredRole AND that role is the defaultRole. A 2-role setup\n // (e.g., ADMIN + USER, all routes require USER) is a legitimate flat\n // design and is intentionally exempted from this check.\n const rbacRoles = (authConfig as Record<string, unknown>)['rbacRoles'];\n const rbacDefaultRole = (authConfig as Record<string, unknown>)['rbacDefaultRole'];\n const protectedRoutes = (authConfig as Record<string, unknown>)['protectedRoutes'];\n if (\n Array.isArray(rbacRoles) &&\n rbacRoles.length > 2 &&\n Array.isArray(protectedRoutes) &&\n protectedRoutes.length > 0 &&\n typeof rbacDefaultRole === 'string'\n ) {\n type RouteEntry = string | { pattern: string; requiredRole?: string };\n const uniqueRoles = new Set(\n (protectedRoutes as RouteEntry[]).map((r) =>\n typeof r === 'string' ? rbacDefaultRole : (r.requiredRole ?? rbacDefaultRole)\n )\n );\n if (uniqueRoles.size === 1 && uniqueRoles.has(rbacDefaultRole)) {\n return {\n success: false,\n error: {\n message:\n `RBAC theatre detected: all ${protectedRoutes.length} protectedRoutes require ` +\n `'${rbacDefaultRole}' (the defaultRole), but ${rbacRoles.length} roles are defined. ` +\n `This defeats the RBAC hierarchy. Each route MUST have the minimum role required ` +\n `for its function. See auth-otter PER-ROUTE RBAC GRANULARITY section.`,\n },\n };\n }\n }\n\n return { success: true };\n },\n\n // swp-4071 — opt-in cross-reference validator for the services phase.\n // If workflow-config exists and declares serviceHooks, every hook ref must resolve\n // to a flow or state-machine in this services-config artifact.\n // Skips gracefully when wizard-otter didn't run (Pro-only install) or declared no hooks.\n services: (artifact) => {\n const workflowArtifactPath = join(cwd, '.stackwright', 'artifacts', 'workflow-config.json');\n if (!existsSync(workflowArtifactPath)) {\n // No workflow artifact — wizard-otter didn't run for this project. Skip.\n return { success: true };\n }\n\n let workflowArtifact: {\n serviceHooks?: Array<{ ref: string; kind?: string; purpose?: string }>;\n };\n try {\n workflowArtifact = JSON.parse(readFileSync(workflowArtifactPath, 'utf-8')) as {\n serviceHooks?: Array<{ ref: string; kind?: string; purpose?: string }>;\n };\n } catch {\n // Workflow artifact corrupt — that's the workflow phase's problem, not ours. Skip.\n return { success: true };\n }\n\n const declaredHooks = workflowArtifact.serviceHooks ?? [];\n if (declaredHooks.length === 0) {\n return { success: true }; // wizard declared no hooks\n }\n\n // Extract flow + state-machine names from this services-config artifact.\n // The services artifact has `flows` and `workflows` (state-machines) at the\n // top level — NOT nested under a 'services' key.\n const flows = (artifact['flows'] as Array<{ name: string }> | undefined) ?? [];\n const workflows = (artifact['workflows'] as Array<{ name: string }> | undefined) ?? [];\n const flowNames = new Set([...flows.map((f) => f.name), ...workflows.map((w) => w.name)]);\n\n const unresolved = declaredHooks.filter((h) => !flowNames.has(h.ref));\n if (unresolved.length > 0) {\n return {\n success: false,\n error: {\n message:\n `Workflow declared ${declaredHooks.length} service hook(s) but ` +\n `${unresolved.length} unresolved by services-config: ` +\n unresolved\n .map((h) => `${h.ref} (${h.kind ?? 'unknown'}: ${h.purpose ?? 'no purpose'})`)\n .join('; ') +\n '. Either add matching flows to services-config OR remove the hooks from the workflow YAML.',\n },\n };\n }\n\n return { success: true };\n },\n\n // qa artifact — permissive validator: requires version + generatedBy + skipped.\n // When skipped=true: skipReason must be a string.\n // When skipped=false: findings must be an array and summary must be an object.\n qa: (artifact) => {\n const skipped = artifact['skipped'];\n if (typeof skipped !== 'boolean') {\n return {\n success: false,\n error: { message: '\"skipped\" must be a boolean (true or false).' },\n };\n }\n\n if (skipped === true) {\n if (typeof artifact['skipReason'] !== 'string') {\n return {\n success: false,\n error: { message: 'When skipped=true, \"skipReason\" must be a string.' },\n };\n }\n return { success: true };\n }\n\n // skipped=false: require findings array and summary object\n if (!Array.isArray(artifact['findings'])) {\n return {\n success: false,\n error: { message: 'When skipped=false, \"findings\" must be an array.' },\n };\n }\n if (typeof artifact['summary'] !== 'object' || artifact['summary'] === null) {\n return {\n success: false,\n error: { message: 'When skipped=false, \"summary\" must be an object.' },\n };\n }\n return { success: true };\n },\n };\n\n const zodValidator = PHASE_ZOD_VALIDATORS[phase];\n if (zodValidator) {\n const zodResult = zodValidator(artifact);\n if (!zodResult.success) {\n const result: InvalidArtifactResult = {\n valid: false,\n phase,\n violation: 'schema-mismatch',\n retryPrompt: `Your artifact failed schema validation: ${zodResult.error?.message}. Fix these fields and return the corrected JSON artifact.`,\n };\n return { text: JSON.stringify(result), isError: false };\n }\n }\n\n // 6. All good — write it and sign it\n try {\n const artifactsDir = join(cwd, '.stackwright', 'artifacts');\n mkdirSync(artifactsDir, { recursive: true });\n const artifactFile = PHASE_ARTIFACT[phase];\n const artifactPath = join(artifactsDir, artifactFile);\n\n // Serialize once — sign and write the same bytes (zero TOCTOU window)\n const serialized = JSON.stringify(artifact, null, 2) + '\\n';\n const artifactBytes = Buffer.from(serialized, 'utf-8');\n safeWriteSync(artifactPath, serialized);\n\n // Sign the artifact (non-blocking — signing failure doesn't prevent write)\n let signed = false;\n try {\n const { privateKey } = loadPipelineKeys(cwd);\n const sig = signArtifact(artifactBytes, privateKey);\n const signerOtter = PHASE_TO_OTTER[phase];\n saveArtifactSignature(cwd, artifactFile, sig, signerOtter);\n signed = true;\n } catch {\n // Signing is defense-in-depth — pipeline continues without signatures\n // if keys haven't been initialized (e.g., backwards-compat with old pipelines)\n }\n\n // Update pipeline state\n const state = readState(cwd);\n if (!state.phases[phase]) state.phases[phase] = defaultPhaseStatus();\n const ps = state.phases[phase]!; // guaranteed by line above\n ps.artifactWritten = true;\n writeState(cwd, state);\n\n const topKeys = Object.keys(artifact).slice(0, 5).join(', ');\n const result: ValidArtifactResult = {\n valid: true,\n phase,\n artifactPath,\n summary: `Wrote ${artifactFile} (keys: ${topKeys}${Object.keys(artifact).length > 5 ? ', ...' : ''})${signed ? ' [signed]' : ''}`,\n };\n return { text: JSON.stringify(result), isError: false };\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n return { text: JSON.stringify({ error: true, phase, message }), isError: true };\n }\n}\n\n// ---------------------------------------------------------------------------\n// MCP tool registration\n// ---------------------------------------------------------------------------\n\nexport function registerPipelineTools(server: McpServer): void {\n const DESC = 'Writes state to .stackwright/ — the filesystem is the state machine.';\n const res = (r: { text: string; isError: boolean }) => ({\n content: [{ type: 'text' as const, text: r.text }],\n isError: r.isError,\n });\n\n server.tool(\n 'stackwright_pro_get_pipeline_state',\n `Read pipeline state from .stackwright/pipeline-state.json. ${DESC}`,\n {},\n async () => res(handleGetPipelineState())\n );\n\n server.tool(\n 'stackwright_pro_set_pipeline_state',\n `Atomic read→modify→write pipeline state. ${DESC}`,\n {\n phase: z.string().optional().describe('Phase to update, e.g. \"designer\"'),\n field: z\n .enum(['questionsCollected', 'answered', 'executed', 'artifactWritten'])\n .optional()\n .describe('Boolean field to set'),\n value: boolCoerce(z.boolean().optional()).describe(\n 'Value for the field — must be a JSON boolean (true/false), NOT the string \"true\"/\"false\"'\n ),\n status: z\n .enum(['setup', 'questions', 'execution', 'done'])\n .optional()\n .describe('Top-level status override'),\n incrementRetry: boolCoerce(z.boolean().optional()).describe(\n 'Bump retryCount by 1 — must be a JSON boolean'\n ),\n updates: jsonCoerce(\n z\n .array(\n z.object({\n phase: z.string(),\n field: z.enum(['questionsCollected', 'answered', 'executed', 'artifactWritten']),\n value: z.boolean(),\n })\n )\n .optional()\n ).describe('Batch updates — apply multiple field changes in one atomic read→modify→write'),\n },\n async (args) =>\n res(\n handleSetPipelineState({\n ...(args.phase != null ? { phase: args.phase } : {}),\n ...(args.field != null ? { field: args.field } : {}),\n ...(args.value != null ? { value: args.value } : {}),\n ...(args.status != null ? { status: args.status } : {}),\n ...(args.incrementRetry != null ? { incrementRetry: args.incrementRetry } : {}),\n ...(args.updates != null ? { updates: args.updates as BatchStateUpdate[] } : {}),\n })\n )\n );\n\n server.tool(\n 'stackwright_pro_check_execution_ready',\n `Check all phases have answer files in .stackwright/answers/. If phase is provided, check only that phase. ${DESC}`,\n {\n phase: z\n .string()\n .optional()\n .describe(\"If provided, check only this phase's readiness. Omit to check all phases.\"),\n },\n async ({ phase }) => res(handleCheckExecutionReady(undefined, phase))\n );\n\n server.tool(\n 'stackwright_pro_get_ready_phases',\n `Return phases whose dependencies are all satisfied (executed=true). Use to determine which phases can run next — enables wave-based execution. ${DESC}`,\n {},\n async () => res(handleGetReadyPhases())\n );\n\n server.tool(\n 'stackwright_pro_list_artifacts',\n `List phase artifacts in .stackwright/artifacts/ with completedCount/totalCount. ${DESC}`,\n {},\n async () => res(handleListArtifacts())\n );\n\n server.tool(\n 'stackwright_pro_write_phase_questions',\n `Parse otter question-collection response → .stackwright/questions/{phase}.json. Specialists may also call this directly with a parsed questions array. ${DESC}`,\n {\n phase: z\n .string()\n .optional()\n .describe('Phase name, e.g. \"designer\" (required for direct write)'),\n responseText: z\n .string()\n .optional()\n .describe('Raw LLM response from QUESTION_COLLECTION_MODE'),\n questions: jsonCoerce(z.array(z.any()).optional()).describe(\n 'Questions array for direct specialist write'\n ),\n },\n async ({ phase, responseText, questions }) => {\n // Direct write path: specialist calls with parsed array (no responseText parsing)\n if (phase && questions && Array.isArray(questions)) {\n if (!SAFE_PHASE.test(phase)) {\n return {\n content: [\n { type: 'text' as const, text: JSON.stringify({ error: `Invalid phase name` }) },\n ],\n isError: true,\n };\n }\n const questionsDir = join(process.cwd(), '.stackwright', 'questions');\n mkdirSync(questionsDir, { recursive: true });\n const outPath = join(questionsDir, `${phase}.json`);\n writeFileSync(\n outPath,\n JSON.stringify({ phase, questions, writtenAt: new Date().toISOString() }, null, 2)\n );\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({\n written: true,\n phase,\n count: (questions as unknown[]).length,\n }),\n },\n ],\n };\n }\n // Fallback: parse from raw LLM responseText\n return res(\n handleWritePhaseQuestions({ phase: phase ?? '', responseText: responseText ?? '' })\n );\n }\n );\n\n server.tool(\n 'stackwright_pro_build_specialist_prompt',\n `Assemble execution prompt from answers + upstream artifacts. Foreman passes verbatim. ${DESC}`,\n { phase: z.string().describe('Phase to build prompt for, e.g. \"pages\"') },\n async ({ phase }) => res(handleBuildSpecialistPrompt({ phase }))\n );\n\n server.tool(\n 'stackwright_pro_validate_artifact',\n `Validate and write artifact to .stackwright/artifacts/. Returns retryPrompt on failure. ${DESC}`,\n {\n phase: z.string().describe('Phase that produced this artifact, e.g. \"designer\"'),\n responseText: z\n .string()\n .optional()\n .describe('Raw response text from the specialist otter (Foreman-mediated path, legacy)'),\n artifact: jsonCoerce(z.record(z.string(), z.unknown()).optional()).describe(\n 'Artifact object to validate and write directly (specialist direct path — skips off-script detection and JSON parsing)'\n ),\n },\n async ({ phase, responseText, artifact }) => {\n if (artifact) {\n return res(\n handleValidateArtifact({ phase, artifact: artifact as Record<string, unknown> })\n );\n }\n return res(handleValidateArtifact({ phase, responseText: responseText ?? '' }));\n }\n );\n}\n","/**\n * LLM Input Coercion Utilities — \"Sinks Not Pipes\"\n *\n * LLMs frequently serialize complex values to JSON strings before passing them\n * as MCP tool arguments. These utilities wrap Zod schemas with z.preprocess\n * steps that coerce the common mis-serializations back to native types before\n * Zod validates them.\n *\n * Usage:\n * import { jsonCoerce, boolCoerce, numCoerce } from '../coerce.js';\n * // In a server.tool() schema:\n * myArray: jsonCoerce(z.array(z.string())).describe('...'),\n * myBool: boolCoerce(z.boolean().optional()).describe('...'),\n * myNum: numCoerce(z.number().optional()).describe('...'),\n *\n * Design note: z.preprocess uses the INNER schema for JSON Schema generation,\n * so the MCP tool's inputSchema still shows the correct native type to the LLM.\n * The coercion is invisible to the model — it only fires when the model sends\n * the wrong type.\n */\nimport { z } from 'zod';\n\n/**\n * JSON-coerce: If the input is a string, attempt JSON.parse before validation.\n * Handles LLM string-serialization of arrays (`\"[...]\"`), objects (`\"{...}\"`),\n * and records. If JSON.parse fails, the original string is passed through and\n * Zod produces a clear type error.\n */\nexport function jsonCoerce<T extends z.ZodTypeAny>(schema: T) {\n return z.preprocess((v) => {\n if (typeof v === 'string') {\n try {\n return JSON.parse(v);\n } catch {\n return v; // Zod will reject it with a clear error\n }\n }\n return v;\n }, schema);\n}\n\n/**\n * Bool-coerce: Converts the strings \"true\" and \"false\" to their boolean\n * equivalents. Uses explicit string matching — NOT JavaScript truthiness —\n * so the string \"false\" correctly maps to boolean false.\n */\nexport function boolCoerce<T extends z.ZodTypeAny>(schema: T) {\n return z.preprocess((v) => (v === 'true' ? true : v === 'false' ? false : v), schema);\n}\n\n/**\n * Num-coerce: Converts pure numeric strings (\"42\", \"3.14\") to numbers.\n * Non-numeric strings pass through unchanged (Zod produces a clear error).\n */\nexport function numCoerce<T extends z.ZodTypeAny>(schema: T) {\n return z.preprocess((v) => {\n if (typeof v === 'string' && v.trim() !== '') {\n const n = Number(v);\n if (!Number.isNaN(n)) return n;\n }\n return v;\n }, schema);\n}\n","/**\n * Artifact Signing — Cryptographic Pipeline Artifact Protection\n * ==============================================================\n * Closes the mid-pipeline TOCTOU gap where artifacts written by one otter\n * can be tampered with before the next otter reads them.\n *\n * integrity.ts handles SHA-256 pinning of otter *definition* files at startup.\n * This module protects pipeline *artifacts* (`.stackwright/artifacts/`) with\n * ECDSA P-384 signatures. Ephemeral key pairs are per-pipeline-run, destroyed\n * after completion.\n */\nimport {\n createHash,\n generateKeyPairSync,\n createPublicKey,\n createPrivateKey,\n sign,\n verify,\n timingSafeEqual,\n KeyObject,\n} from 'crypto';\nimport {\n readFileSync,\n writeFileSync,\n existsSync,\n mkdirSync,\n lstatSync,\n unlinkSync,\n readdirSync,\n} from 'fs';\nimport { join } from 'path';\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst ALGORITHM = 'ECDSA-P384-SHA384' as const;\nconst KEY_FILE = 'pipeline-keys.json';\nconst KEY_DIR = '.stackwright';\nconst SIGNATURE_MANIFEST = 'signatures.json';\nconst ARTIFACTS_DIR = '.stackwright/artifacts';\n\n// ---------------------------------------------------------------------------\n// Exported types & interfaces\n// ---------------------------------------------------------------------------\n\n/** Signature record for a single artifact. */\nexport interface ArtifactSignature {\n digest: string;\n signature: string;\n algorithm: typeof ALGORITHM;\n signedAt: string;\n}\n\n/** Manifest entry — extends ArtifactSignature with signer identity. */\nexport interface ManifestEntry extends ArtifactSignature {\n signedBy: string;\n}\n\n/** On-disk shape of `.stackwright/artifacts/signatures.json`. */\nexport interface SignatureManifest {\n version: '1.0';\n algorithm: typeof ALGORITHM;\n signatures: Record<string, ManifestEntry>;\n}\n\n/** On-disk shape of `.stackwright/pipeline-keys.json`. */\nexport interface PipelineKeyFile {\n version: '1.0';\n algorithm: typeof ALGORITHM;\n createdAt: string;\n publicKeyPem: string;\n privateKeyPem: string;\n}\n\n/** Structured audit event for a signature failure. */\nexport interface SignatureAuditEvent {\n artifactFilename: string;\n expectedDigest: string;\n actualDigest: string;\n phase: string;\n timestamp: string;\n source: string;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/** Reject symlinks at a given path. No-op if path doesn't exist. */\nfunction rejectSymlink(filePath: string, context: string): void {\n if (!existsSync(filePath)) return;\n const stat = lstatSync(filePath);\n if (stat.isSymbolicLink()) {\n throw new Error(`Security: refusing to follow symlink at ${context}: ${filePath}`);\n }\n}\n\n/** Hex-encoded SHA-384 digest of raw bytes. Pure, no I/O. */\nfunction computeSha384(data: Buffer): string {\n return createHash('sha384').update(data).digest('hex');\n}\n\n/** Constant-time comparison of two hex digest strings. */\nfunction safeDigestEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));\n}\n\n/** Build an empty manifest with the correct shape. */\nfunction emptyManifest(): SignatureManifest {\n return {\n version: '1.0',\n algorithm: ALGORITHM,\n signatures: {},\n };\n}\n\n// ---------------------------------------------------------------------------\n// 1. Ephemeral Key Pair Management (ECDSA P-384)\n// ---------------------------------------------------------------------------\n\n/** Generate an ECDSA P-384 key pair, write to `.stackwright/pipeline-keys.json`. */\nexport function initPipelineKeys(cwd: string): { publicKeyPem: string; fingerprint: string } {\n const keyDir = join(cwd, KEY_DIR);\n const keyPath = join(keyDir, KEY_FILE);\n\n // Reject symlinks on the key file path\n rejectSymlink(keyPath, 'pipeline-keys.json');\n\n // Ensure directory exists\n mkdirSync(keyDir, { recursive: true });\n\n // Generate ECDSA P-384 key pair\n const { publicKey, privateKey } = generateKeyPairSync('ec', {\n namedCurve: 'P-384',\n });\n\n const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;\n const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;\n\n // SHA-256 fingerprint of the public key PEM\n const fingerprint = createHash('sha256').update(publicKeyPem).digest('hex');\n\n const keyFile: PipelineKeyFile = {\n version: '1.0',\n algorithm: ALGORITHM,\n createdAt: new Date().toISOString(),\n publicKeyPem,\n privateKeyPem,\n };\n\n writeFileSync(keyPath, JSON.stringify(keyFile, null, 2), { encoding: 'utf-8' });\n\n return { publicKeyPem, fingerprint };\n}\n\n/** Load pipeline key pair from disk. Validates PEM, rejects symlinks. */\nexport function loadPipelineKeys(cwd: string): { privateKey: KeyObject; publicKey: KeyObject } {\n const keyPath = join(cwd, KEY_DIR, KEY_FILE);\n\n // Reject symlinks\n rejectSymlink(keyPath, 'pipeline-keys.json');\n\n if (!existsSync(keyPath)) {\n throw new Error('Pipeline keys not found — call initPipelineKeys() first');\n }\n\n let raw: string;\n try {\n raw = readFileSync(keyPath, 'utf-8');\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`Cannot read pipeline keys: ${msg}`, { cause: err });\n }\n\n let parsed: PipelineKeyFile;\n try {\n parsed = JSON.parse(raw) as PipelineKeyFile;\n } catch {\n throw new Error('Pipeline keys file is not valid JSON');\n }\n\n // Basic PEM format validation\n if (\n typeof parsed.publicKeyPem !== 'string' ||\n !parsed.publicKeyPem.includes('-----BEGIN PUBLIC KEY-----')\n ) {\n throw new Error('Invalid public key PEM in pipeline keys file');\n }\n if (typeof parsed.privateKeyPem !== 'string' || !parsed.privateKeyPem.includes('-----BEGIN')) {\n throw new Error('Invalid private key PEM in pipeline keys file');\n }\n\n const publicKey = createPublicKey(parsed.publicKeyPem);\n const privateKey = createPrivateKey(parsed.privateKeyPem);\n\n return { privateKey, publicKey };\n}\n\n/** Overwrite private key material with zeros then delete. Idempotent. */\nexport function destroyPipelineKeys(cwd: string): void {\n const keyPath = join(cwd, KEY_DIR, KEY_FILE);\n\n if (!existsSync(keyPath)) return;\n\n // Reject symlinks even during destruction\n rejectSymlink(keyPath, 'pipeline-keys.json (destroy)');\n\n // Read → overwrite private key material with zeros → delete\n try {\n const raw = readFileSync(keyPath, 'utf-8');\n const parsed = JSON.parse(raw) as PipelineKeyFile;\n parsed.privateKeyPem = '0'.repeat(parsed.privateKeyPem.length);\n parsed.publicKeyPem = '0'.repeat(parsed.publicKeyPem.length);\n writeFileSync(keyPath, JSON.stringify(parsed, null, 2), { encoding: 'utf-8' });\n } catch {\n // Best-effort overwrite — proceed to delete regardless\n }\n\n try {\n unlinkSync(keyPath);\n } catch {\n // Already gone — idempotent\n }\n}\n\n// ---------------------------------------------------------------------------\n// 2. Artifact Signing\n// ---------------------------------------------------------------------------\n\n/** Sign artifact bytes with ECDSA P-384. Returns structured signature record. */\nexport function signArtifact(artifactBytes: Buffer, privateKey: KeyObject): ArtifactSignature {\n const digest = computeSha384(artifactBytes);\n const sig = sign('SHA384', artifactBytes, privateKey);\n\n return {\n digest,\n signature: sig.toString('base64'),\n algorithm: ALGORITHM,\n signedAt: new Date().toISOString(),\n };\n}\n\n// ---------------------------------------------------------------------------\n// 3. Artifact Verification\n// ---------------------------------------------------------------------------\n\n/** Verify ECDSA signature AND constant-time SHA-384 digest. Both must pass. */\nexport function verifyArtifact(\n artifactBytes: Buffer,\n signature: ArtifactSignature,\n publicKey: KeyObject\n): boolean {\n // Layer 1: ECDSA signature verification\n const sigValid = verify(\n 'SHA384',\n artifactBytes,\n publicKey,\n Buffer.from(signature.signature, 'base64')\n );\n\n if (!sigValid) return false;\n\n // Layer 2: Constant-time digest comparison\n const actualDigest = computeSha384(artifactBytes);\n return safeDigestEqual(actualDigest, signature.digest);\n}\n\n// ---------------------------------------------------------------------------\n// 4. Signature Manifest Management\n// ---------------------------------------------------------------------------\n\n/** Load signature manifest. Returns empty manifest if file doesn't exist. */\nexport function loadSignatureManifest(cwd: string): SignatureManifest {\n const manifestPath = join(cwd, ARTIFACTS_DIR, SIGNATURE_MANIFEST);\n\n rejectSymlink(manifestPath, 'signatures.json');\n\n if (!existsSync(manifestPath)) return emptyManifest();\n\n let raw: string;\n try {\n raw = readFileSync(manifestPath, 'utf-8');\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`Cannot read signature manifest: ${msg}`, { cause: err });\n }\n\n try {\n const parsed = JSON.parse(raw) as SignatureManifest;\n // Basic shape validation — don't trust what's on disk blindly\n if (parsed.version !== '1.0' || typeof parsed.signatures !== 'object') {\n throw new Error('Malformed signature manifest: invalid version or missing signatures object');\n }\n return parsed;\n } catch (err) {\n if (err instanceof Error && err.message.startsWith('Malformed')) throw err;\n throw new Error('Signature manifest is not valid JSON', { cause: err });\n }\n}\n\n/** Save a signature entry for a specific artifact. Read-modify-write. */\nexport function saveArtifactSignature(\n cwd: string,\n artifactFilename: string,\n sig: ArtifactSignature,\n signerOtter: string\n): void {\n const artifactsDir = join(cwd, ARTIFACTS_DIR);\n const manifestPath = join(artifactsDir, SIGNATURE_MANIFEST);\n\n rejectSymlink(manifestPath, 'signatures.json (save)');\n\n // Ensure artifacts directory exists\n mkdirSync(artifactsDir, { recursive: true });\n\n const manifest = loadSignatureManifest(cwd);\n\n manifest.signatures[artifactFilename] = {\n ...sig,\n signedBy: signerOtter,\n };\n\n writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), { encoding: 'utf-8' });\n}\n\n/** Retrieve signature for a specific artifact, or null if absent. */\nexport function getArtifactSignature(\n cwd: string,\n artifactFilename: string\n): ArtifactSignature | null {\n const manifest = loadSignatureManifest(cwd);\n const entry = manifest.signatures[artifactFilename];\n if (!entry) return null;\n\n // Return just the ArtifactSignature fields (strip signedBy)\n return {\n digest: entry.digest,\n signature: entry.signature,\n algorithm: entry.algorithm,\n signedAt: entry.signedAt,\n };\n}\n\n// ---------------------------------------------------------------------------\n// 5. Audit Events — SOC2 / FedRAMP / DoD ATO compliance\n// ---------------------------------------------------------------------------\n\n/**\n * Emit a structured ARTIFACT_SIGNATURE_FAIL audit event to stderr.\n *\n * Line prefix allows log shippers (FluentBit, syslog, CloudWatch, Splunk)\n * to route events via simple string filter before JSON parsing.\n */\nexport function emitSignatureAuditEvent(params: SignatureAuditEvent): void {\n const record = JSON.stringify({\n level: 'AUDIT',\n event: 'ARTIFACT_SIGNATURE_FAIL',\n timestamp: params.timestamp,\n source: params.source,\n artifactFilename: params.artifactFilename,\n expectedDigest: params.expectedDigest,\n actualDigest: params.actualDigest,\n phase: params.phase,\n });\n process.stderr.write(`ARTIFACT_SIGNATURE_FAIL ${record}\\n`);\n}\n\n// ---------------------------------------------------------------------------\n// 6. MCP Tool Registration\n// ---------------------------------------------------------------------------\n\n/** Per-artifact verification result returned by the MCP tool. */\nexport interface ArtifactVerificationResult {\n filename: string;\n verified: boolean;\n error?: string;\n signedBy?: string;\n signedAt?: string;\n}\n\n/** Register artifact signing verification tool on the MCP server. */\nexport function registerArtifactSigningTools(server: McpServer): void {\n server.tool(\n 'stackwright_pro_verify_artifact_signatures',\n 'Verify ECDSA P-384 signatures for all pipeline artifacts in .stackwright/artifacts/. ' +\n 'Auto-discovers keys from .stackwright/pipeline-keys.json. ' +\n 'Returns per-artifact verification status.',\n {\n cwd: z.string().optional().describe('Project root directory. Defaults to process.cwd().'),\n },\n async ({ cwd: cwdParam }) => {\n const cwd = cwdParam ?? process.cwd();\n\n // 1. Load pipeline keys\n let publicKey: KeyObject;\n try {\n const keys = loadPipelineKeys(cwd);\n publicKey = keys.publicKey;\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({\n error: true,\n message: `Cannot load pipeline keys: ${msg}`,\n }),\n },\n ],\n isError: true,\n };\n }\n\n // 2. Load signature manifest\n let manifest: SignatureManifest;\n try {\n manifest = loadSignatureManifest(cwd);\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({\n error: true,\n message: `Cannot load signature manifest: ${msg}`,\n }),\n },\n ],\n isError: true,\n };\n }\n\n // 3. Discover artifact files on disk\n const artifactsPath = join(cwd, ARTIFACTS_DIR);\n let artifactFiles: string[] = [];\n try {\n if (existsSync(artifactsPath)) {\n artifactFiles = readdirSync(artifactsPath).filter(\n (f) => f.endsWith('.json') && f !== SIGNATURE_MANIFEST\n );\n }\n } catch {\n // If we can't read the directory, we'll report zero artifacts\n }\n\n // 4. Verify each artifact\n const results: ArtifactVerificationResult[] = [];\n let hasFailure = false;\n\n for (const filename of artifactFiles) {\n const filePath = join(artifactsPath, filename);\n\n // Symlink guard\n try {\n rejectSymlink(filePath, `artifact ${filename}`);\n } catch {\n results.push({\n filename,\n verified: false,\n error: 'Refusing to verify symlink',\n });\n hasFailure = true;\n continue;\n }\n\n // Read artifact bytes\n let artifactBytes: Buffer;\n try {\n artifactBytes = readFileSync(filePath);\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n results.push({\n filename,\n verified: false,\n error: `Cannot read artifact: ${msg}`,\n });\n hasFailure = true;\n continue;\n }\n\n // Look up signature in manifest\n const entry = manifest.signatures[filename];\n if (!entry) {\n results.push({\n filename,\n verified: false,\n error: 'No signature found in manifest',\n });\n hasFailure = true;\n continue;\n }\n\n // Verify\n const sig: ArtifactSignature = {\n digest: entry.digest,\n signature: entry.signature,\n algorithm: entry.algorithm,\n signedAt: entry.signedAt,\n };\n\n const verified = verifyArtifact(artifactBytes, sig, publicKey);\n\n if (!verified) {\n const actualDigest = computeSha384(artifactBytes);\n emitSignatureAuditEvent({\n artifactFilename: filename,\n expectedDigest: sig.digest,\n actualDigest,\n phase: entry.signedBy ?? 'unknown',\n timestamp: new Date().toISOString(),\n source: 'stackwright_pro_verify_artifact_signatures',\n });\n\n results.push({\n filename,\n verified: false,\n error: `Signature verification failed — artifact may have been tampered with`,\n signedBy: entry.signedBy,\n signedAt: entry.signedAt,\n });\n hasFailure = true;\n } else {\n results.push({\n filename,\n verified: true,\n signedBy: entry.signedBy,\n signedAt: entry.signedAt,\n });\n }\n }\n\n // 5. Report artifacts in manifest that are missing from disk\n for (const manifestFilename of Object.keys(manifest.signatures)) {\n if (!artifactFiles.includes(manifestFilename)) {\n results.push({\n filename: manifestFilename,\n verified: false,\n error: 'Artifact referenced in manifest but missing from disk',\n });\n hasFailure = true;\n }\n }\n\n const verifiedCount = results.filter((r) => r.verified).length;\n const failedCount = results.filter((r) => !r.verified).length;\n\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({\n totalArtifacts: artifactFiles.length,\n verifiedCount,\n failedCount,\n results,\n ...(hasFailure\n ? {\n error:\n 'SIGNATURE VERIFICATION FAILED: One or more artifact signatures are invalid. ' +\n 'Do not proceed — artifacts may have been tampered with.',\n }\n : {}),\n }),\n },\n ],\n isError: hasFailure,\n };\n }\n );\n}\n","/**\n * Get Schema Tool — \"Sinks Not Pipes\"\n *\n * Returns a compact, LLM-friendly representation of a canonical Zod schema.\n * Derived programmatically via z.toJSONSchema() — never hardcoded — so schema\n * and documentation can never drift from each other.\n *\n * Used by the foreman during build_specialist_prompt to inject canonical schema\n * references into specialist prompts. This is the source-of-truth injection layer\n * for swp-bf6: specialists receive the actual schema, not a prose copy.\n */\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\nimport {\n WorkflowStepSchema,\n WorkflowDefinitionSchema,\n WorkflowFieldSchema,\n WorkflowActionSchema,\n authConfigSchema,\n} from '@stackwright-pro/types';\nimport {\n SUPPORTED_SCHEMA_NAMES,\n FIELD_SYNONYMS,\n type SchemaName,\n} from './validate-yaml-fragment.js';\n\n// Re-export so callers can import from one place\nexport { SUPPORTED_SCHEMA_NAMES };\n\n// ---------------------------------------------------------------------------\n// Inline navigation schema (mirrors validate-yaml-fragment.ts — DRY violation\n// risk is acceptable here: both files are small and colocated. The alternative\n// (a shared-types file for just two schemas) would be over-engineering.)\n// ---------------------------------------------------------------------------\n\nconst NavigationLinkSchema: z.ZodType<unknown> = z.lazy(() =>\n z.object({\n label: z.string().min(1),\n href: z.string().min(1),\n children: z.array(NavigationLinkSchema).optional(),\n })\n);\n\nconst NavigationSectionSchema = z.object({\n section: z.string().min(1),\n items: z.array(NavigationLinkSchema),\n});\n\nconst NavigationItemSchema = z.union([\n NavigationLinkSchema as z.ZodObject<any>,\n NavigationSectionSchema,\n]);\n\n// ---------------------------------------------------------------------------\n// Schema registry (same set as validate-yaml-fragment)\n// ---------------------------------------------------------------------------\n\nfunction getZodSchema(name: SchemaName): z.ZodTypeAny {\n switch (name) {\n case 'workflow_step':\n return WorkflowStepSchema;\n case 'workflow_definition':\n return WorkflowDefinitionSchema;\n case 'workflow_field':\n return WorkflowFieldSchema;\n case 'workflow_action':\n return WorkflowActionSchema;\n case 'navigation_item':\n return NavigationItemSchema;\n case 'auth_config':\n return authConfigSchema;\n }\n}\n\n// ---------------------------------------------------------------------------\n// JSON Schema → compact text format\n// ---------------------------------------------------------------------------\n\ntype JsonSchemaNode = {\n type?: string | string[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n enum?: unknown[];\n const?: unknown;\n oneOf?: JsonSchemaNode[];\n anyOf?: JsonSchemaNode[];\n items?: JsonSchemaNode;\n maxLength?: number;\n minLength?: number;\n pattern?: string;\n minimum?: number;\n maximum?: number;\n additionalProperties?: boolean;\n description?: string;\n};\n\nfunction formatType(node: JsonSchemaNode, depth = 0): string {\n if (node.const !== undefined) return JSON.stringify(node.const);\n if (node.enum) return node.enum.map((v) => JSON.stringify(v)).join(' | ');\n\n if (node.oneOf || node.anyOf) {\n const branches = node.oneOf ?? node.anyOf ?? [];\n // Discriminated union — show all variant types\n return branches\n .map((b) => {\n const disc = b.properties\n ? Object.entries(b.properties)\n .filter(([, v]) => v.const !== undefined)\n .map(([k, v]) => `${k}: ${JSON.stringify(v.const)}`)\n .join(', ')\n : '';\n return disc ? `{ ${disc}, ... }` : '{ ... }';\n })\n .join(' | ');\n }\n\n if (node.type === 'array' && node.items) {\n return `${formatType(node.items, depth)}[]`;\n }\n\n if (node.type === 'object' && node.properties && depth < 2) {\n const inner = Object.entries(node.properties)\n .map(([k, v]) => `${k}: ${formatType(v, depth + 1)}`)\n .join(', ');\n return `{ ${inner} }`;\n }\n\n const base = Array.isArray(node.type) ? node.type.join(' | ') : (node.type ?? 'unknown');\n\n const constraints: string[] = [];\n if (node.maxLength !== undefined) constraints.push(`max ${node.maxLength} chars`);\n if (node.minLength !== undefined) constraints.push(`min ${node.minLength} chars`);\n if (node.pattern) constraints.push(`pattern: ${node.pattern}`);\n if (node.minimum !== undefined) constraints.push(`min ${node.minimum}`);\n if (node.maximum !== undefined) constraints.push(`max ${node.maximum}`);\n\n return constraints.length > 0 ? `${base} (${constraints.join(', ')})` : base;\n}\n\nfunction formatObjectSchema(\n node: JsonSchemaNode,\n schemaName: string,\n synonyms: Record<string, string>\n): string {\n const lines: string[] = [];\n const displayName = schemaName\n .split('_')\n .map((w) => w[0].toUpperCase() + w.slice(1))\n .join('');\n\n lines.push(`${displayName}:`);\n\n // Handle discriminated unions (oneOf/anyOf at the top level)\n if (node.oneOf || node.anyOf) {\n const branches = node.oneOf ?? node.anyOf ?? [];\n lines.push(' Discriminated union — choose one variant:');\n for (const branch of branches) {\n const req = new Set(branch.required ?? []);\n const disc = Object.entries(branch.properties ?? {})\n .filter(([, v]) => v.const !== undefined)\n .map(([k, v]) => `${k}: ${JSON.stringify(v.const)}`)\n .join(', ');\n lines.push(` VARIANT (${disc}):`);\n for (const [field, fieldNode] of Object.entries(branch.properties ?? {})) {\n if (fieldNode.const !== undefined) continue; // discriminant shown in header\n const isRequired = req.has(field);\n const hint = synonyms[field] ? ` -- WARNING: use ${field}: not ${synonyms[field]}` : '';\n lines.push(\n ` ${isRequired ? 'REQUIRED' : 'optional'}: ${field}: ${formatType(fieldNode)}${hint}`\n );\n }\n }\n return lines.join('\\n');\n }\n\n // Object schema\n const required = new Set(node.required ?? []);\n const properties = node.properties ?? {};\n\n const requiredFields = Object.entries(properties).filter(([k]) => required.has(k));\n const optionalFields = Object.entries(properties).filter(([k]) => !required.has(k));\n\n if (requiredFields.length > 0) {\n lines.push(' REQUIRED:');\n for (const [field, fieldNode] of requiredFields) {\n const typeStr = formatType(fieldNode);\n const warn = synonyms[field] ? ` -- CAUTION: this schema uses ${field}` : '';\n // Attach warnings for the *correct* field that is also a synonym target\n // Actually: warn for wrong names that newcomers use instead of this field\n lines.push(` ${field}: ${typeStr}${warn}`);\n }\n }\n\n if (optionalFields.length > 0) {\n lines.push(' OPTIONAL:');\n for (const [field, fieldNode] of optionalFields) {\n const typeStr = formatType(fieldNode);\n lines.push(` ${field}: ${typeStr}`);\n }\n }\n\n return lines.join('\\n');\n}\n\n// ---------------------------------------------------------------------------\n// Exported handler\n// ---------------------------------------------------------------------------\n\nexport interface GetSchemaInput {\n schemaName: string;\n}\n\nexport interface GetSchemaResult {\n schemaName: string;\n summary: string;\n synonymWarnings: string[];\n tokenEstimate: number;\n}\n\nexport function handleGetSchema(input: GetSchemaInput): GetSchemaResult | { error: string } {\n const { schemaName } = input;\n\n if (!(SUPPORTED_SCHEMA_NAMES as readonly string[]).includes(schemaName)) {\n return {\n error: `Unknown schemaName: \"${schemaName}\". Supported: ${SUPPORTED_SCHEMA_NAMES.join(', ')}`,\n };\n }\n\n const name = schemaName as SchemaName;\n const schema = getZodSchema(name);\n const synonyms = FIELD_SYNONYMS[name];\n\n // Derive schema summary from Zod via JSON Schema conversion\n let jsonSchema: JsonSchemaNode;\n try {\n // toJSONSchema strips $schema wrapper — we work with the raw shape\n const raw = z.toJSONSchema(schema, { unrepresentable: 'any' }) as JsonSchemaNode;\n // Remove $schema field (not useful for LLM consumption)\n jsonSchema = raw;\n } catch {\n // Fallback for lazy schemas (NavigationItem uses z.lazy)\n jsonSchema = { type: 'object', description: 'Schema introspection unavailable for this type' };\n }\n\n const summary = formatObjectSchema(jsonSchema, name, synonyms);\n\n // Append synonym warnings as a dedicated section\n const synonymWarnings = Object.entries(synonyms).map(\n ([wrong, correct]) => ` WARNING: use \"${correct}\" — NOT \"${wrong}\"`\n );\n\n // Rough token estimate: ~4 chars per token\n const fullText = [\n summary,\n '',\n 'FIELD NAME WARNINGS (common drift patterns):',\n ...synonymWarnings,\n ].join('\\n');\n const tokenEstimate = Math.ceil(fullText.length / 4);\n\n return {\n schemaName: name,\n summary,\n synonymWarnings,\n tokenEstimate,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Convenience: get schemas for a set of phase-relevant schema names\n// ---------------------------------------------------------------------------\n\n/** Maps pipeline phase → schema names relevant to that phase */\nexport const PHASE_SCHEMA_NAMES: Partial<Record<string, readonly SchemaName[]>> = {\n workflow: ['workflow_step', 'workflow_definition', 'workflow_field', 'workflow_action'],\n pages: ['navigation_item'],\n dashboard: ['navigation_item'],\n polish: ['navigation_item'],\n auth: ['auth_config'],\n};\n\n/**\n * Returns a compact schema reference block for injection into specialist prompts.\n * Called by handleBuildSpecialistPrompt — gives specialists the canonical\n * schema contract without requiring MCP access in sub-agents.\n */\nexport function buildSchemaReferenceBlock(phase: string): string | null {\n const schemaNames = PHASE_SCHEMA_NAMES[phase];\n if (!schemaNames || schemaNames.length === 0) return null;\n\n const sections: string[] = [\n 'CANONICAL_SCHEMA_REFERENCE (authoritative — use exact field names below):',\n '',\n ];\n\n for (const name of schemaNames) {\n const result = handleGetSchema({ schemaName: name });\n if ('error' in result) continue;\n\n sections.push(result.summary);\n\n if (result.synonymWarnings.length > 0) {\n sections.push(' Common mistakes to avoid:');\n sections.push(...result.synonymWarnings);\n }\n sections.push('');\n }\n\n return sections.join('\\n');\n}\n\n// ---------------------------------------------------------------------------\n// MCP tool registration\n// ---------------------------------------------------------------------------\n\nexport function registerGetSchemaTool(server: McpServer): void {\n server.tool(\n 'stackwright_pro_get_schema',\n 'Returns a compact, LLM-friendly summary of a canonical schema derived programmatically from Zod. Includes required/optional fields, types, constraints, and \"did you mean?\" warnings for common field-name drift. Use before authoring YAML to confirm exact field names.',\n {\n schemaName: z\n .enum(SUPPORTED_SCHEMA_NAMES)\n .describe(\n 'Schema to summarize. One of: workflow_step, workflow_definition, workflow_field, workflow_action, navigation_item, auth_config'\n ),\n },\n async ({ schemaName }) => {\n const result = handleGetSchema({ schemaName });\n if ('error' in result) {\n return {\n content: [{ type: 'text', text: JSON.stringify({ error: result.error }) }],\n isError: true,\n };\n }\n return {\n content: [\n {\n type: 'text',\n text: JSON.stringify(result, null, 2),\n },\n ],\n };\n }\n );\n}\n","/**\n * Validate YAML Fragment Tool — \"Sinks Not Pipes\"\n *\n * Parses a YAML snippet and validates it against the canonical Zod schema for\n * the named schema type. Returns actionable errors with \"did you mean?\"\n * suggestions for the most common drift patterns seen across 5 raft postmortems.\n *\n * Otters should call this BEFORE writing YAML to disk — catching drift in-session\n * is orders of magnitude cheaper than a failed prebuild after the raft completes.\n */\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\nimport { load as yamlLoad } from 'js-yaml';\nimport {\n WorkflowStepSchema,\n WorkflowDefinitionSchema,\n WorkflowFieldSchema,\n WorkflowActionSchema,\n authConfigSchema,\n} from '@stackwright-pro/types';\n\n// ---------------------------------------------------------------------------\n// Supported schema names\n// ---------------------------------------------------------------------------\n\nexport const SUPPORTED_SCHEMA_NAMES = [\n 'workflow_step',\n 'workflow_definition',\n 'workflow_field',\n 'workflow_action',\n 'navigation_item',\n 'auth_config',\n] as const;\n\nexport type SchemaName = (typeof SUPPORTED_SCHEMA_NAMES)[number];\n\n// ---------------------------------------------------------------------------\n// Inline navigation schema (not exported from @stackwright-pro/types —\n// mirrors @stackwright/types NavigationItem shape without adding the dep)\n// ---------------------------------------------------------------------------\n\nconst NavigationLinkSchema: z.ZodType<unknown> = z.lazy(() =>\n z.object({\n label: z.string().min(1),\n href: z.string().min(1),\n children: z.array(NavigationLinkSchema).optional(),\n })\n);\n\nconst NavigationSectionSchema = z.object({\n section: z.string().min(1),\n items: z.array(NavigationLinkSchema),\n});\n\nconst NavigationItemSchema = z.union([\n NavigationLinkSchema as z.ZodObject<any>,\n NavigationSectionSchema,\n]);\n\n// ---------------------------------------------------------------------------\n// Known field-name drift patterns from 5 raft postmortems.\n// Key: wrong field name → correct usage hint.\n// ---------------------------------------------------------------------------\n\nexport const FIELD_SYNONYMS: Record<SchemaName, Record<string, string>> = {\n workflow_step: {\n title: 'label',\n name: 'label (at step level, use label: not name:)',\n description: 'message (use message: for step-level descriptive text)',\n style: 'theme.variant — use theme: { variant: \"primary\" } not style: \"primary\"',\n transitions:\n 'on_submit.transition — use on_submit: { transition: \"next_step\" } not transitions: [{then:...}]',\n },\n workflow_definition: {\n title: 'label',\n name: 'label (workflow-level label, not name:)',\n description: 'description is valid here — but message: is not (that is a step field)',\n type: 'id (workflows are identified by id:, not type:)',\n },\n workflow_field: {\n id: 'name (field-level identifier is name:, NOT id:)',\n title: 'label (field display text is label:, NOT title:)',\n description: 'placeholder or label (no description field on WorkflowField)',\n },\n workflow_action: {\n style: 'theme.variant — use theme: { variant: \"primary\" } not style: \"primary\"',\n then: 'transition (action-level next-step is transition:, NOT then:)',\n on_click: 'action: \"service:...\" (extract the service reference from on_click.action)',\n title: 'label (action display text is label:, NOT title:)',\n name: 'label (action display text is label:, NOT name:)',\n },\n navigation_item: {\n children:\n 'items (NavigationSection uses items: not children: — also use section: instead of label: for section headers)',\n label:\n 'section (if this is a nav group/section, use section: \"Title\" and items: [...], not label:)',\n title: 'section (NavigationSection header field is section:, NOT title:)',\n },\n auth_config: {\n method: 'type — auth config discriminates on type: \"oidc\" | \"pki\", NOT method:',\n provider_type: 'type — use type: \"oidc\" or type: \"pki\"',\n strategy: 'type — top-level discriminator is type:, not strategy:',\n },\n};\n\n// ---------------------------------------------------------------------------\n// Schema registry\n// ---------------------------------------------------------------------------\n\nfunction getSchema(name: SchemaName): z.ZodTypeAny {\n switch (name) {\n case 'workflow_step':\n return WorkflowStepSchema;\n case 'workflow_definition':\n return WorkflowDefinitionSchema;\n case 'workflow_field':\n return WorkflowFieldSchema;\n case 'workflow_action':\n return WorkflowActionSchema;\n case 'navigation_item':\n return NavigationItemSchema;\n case 'auth_config':\n return authConfigSchema;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Validation error enrichment\n// ---------------------------------------------------------------------------\n\nexport interface ValidationError {\n path: string;\n message: string;\n didYouMean?: string;\n}\n\nexport interface ValidateYamlFragmentInput {\n schemaName: string;\n yaml: string;\n}\n\nexport interface ValidateYamlFragmentResult {\n valid: boolean;\n errors?: ValidationError[];\n parseError?: string;\n}\n\nfunction enrichErrors(issues: z.ZodIssue[], synonyms: Record<string, string>): ValidationError[] {\n return issues.map((issue) => {\n const pathStr = issue.path.join('.');\n // Check if the last path segment is a known synonym\n const lastSegment = issue.path[issue.path.length - 1];\n const fieldName = typeof lastSegment === 'string' ? lastSegment : undefined;\n const didYouMean = fieldName ? synonyms[fieldName] : undefined;\n return {\n path: pathStr || '(root)',\n message: issue.message,\n ...(didYouMean ? { didYouMean } : {}),\n };\n });\n}\n\n// ---------------------------------------------------------------------------\n// Handler (exported for direct testing)\n// ---------------------------------------------------------------------------\n\nexport function handleValidateYamlFragment(\n input: ValidateYamlFragmentInput\n): ValidateYamlFragmentResult {\n const { schemaName, yaml } = input;\n\n // Guard: unknown schema name\n if (!(SUPPORTED_SCHEMA_NAMES as readonly string[]).includes(schemaName)) {\n return {\n valid: false,\n parseError: `Unknown schemaName: \"${schemaName}\". Supported: ${SUPPORTED_SCHEMA_NAMES.join(', ')}`,\n };\n }\n\n const name = schemaName as SchemaName;\n\n // Parse YAML\n let parsed: unknown;\n try {\n parsed = yamlLoad(yaml);\n } catch (err) {\n return {\n valid: false,\n parseError: `YAML parse error: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n // Validate against Zod schema\n const schema = getSchema(name);\n const result = schema.safeParse(parsed);\n\n if (result.success) {\n return { valid: true };\n }\n\n const synonyms = FIELD_SYNONYMS[name];\n const errors = enrichErrors(result.error.issues, synonyms);\n return { valid: false, errors };\n}\n\n// ---------------------------------------------------------------------------\n// MCP tool registration\n// ---------------------------------------------------------------------------\n\nexport function registerValidateYamlFragmentTool(server: McpServer): void {\n server.tool(\n 'stackwright_pro_validate_yaml_fragment',\n 'Validates a YAML snippet against a canonical Zod schema before writing to disk. Returns actionable errors with \"did you mean?\" suggestions for common field-name drift (title vs label, id vs name, style vs theme.variant, etc.). Call this before stackwright_pro_safe_write to catch schema violations in-session.',\n {\n schemaName: z\n .enum(SUPPORTED_SCHEMA_NAMES)\n .describe(\n 'Schema to validate against. One of: workflow_step, workflow_definition, workflow_field, workflow_action, navigation_item, auth_config'\n ),\n yaml: z.string().describe('YAML string to validate (can also be JSON — js-yaml parses both)'),\n },\n async ({ schemaName, yaml }) => {\n const result = handleValidateYamlFragment({ schemaName, yaml });\n return {\n content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],\n };\n }\n );\n}\n"],"mappings":";AAOA,SAAS,KAAAA,UAAS;;;ACalB,SAAS,SAAS;;;ACYlB,SAAS,KAAAC,UAAS;;;AFNlB,SAAS,oBAAoB,oBAAAC,yBAAwB;;;AGdrD,SAAS,KAAAC,UAAS;AAClB;AAAA,EACE,sBAAAC;AAAA,EACA,4BAAAC;AAAA,EACA,uBAAAC;AAAA,EACA,wBAAAC;AAAA,EACA,oBAAAC;AAAA,OACK;;;ACRP,SAAS,KAAAC,UAAS;AAClB,SAAS,QAAQ,gBAAgB;AACjC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAsBP,IAAM,uBAA2CC,GAAE;AAAA,EAAK,MACtDA,GAAE,OAAO;AAAA,IACP,OAAOA,GAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACvB,MAAMA,GAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACtB,UAAUA,GAAE,MAAM,oBAAoB,EAAE,SAAS;AAAA,EACnD,CAAC;AACH;AAEA,IAAM,0BAA0BA,GAAE,OAAO;AAAA,EACvC,SAASA,GAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACzB,OAAOA,GAAE,MAAM,oBAAoB;AACrC,CAAC;AAED,IAAM,uBAAuBA,GAAE,MAAM;AAAA,EACnC;AAAA,EACA;AACF,CAAC;;;ADtBD,IAAMC,wBAA2CC,GAAE;AAAA,EAAK,MACtDA,GAAE,OAAO;AAAA,IACP,OAAOA,GAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACvB,MAAMA,GAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACtB,UAAUA,GAAE,MAAMD,qBAAoB,EAAE,SAAS;AAAA,EACnD,CAAC;AACH;AAEA,IAAME,2BAA0BD,GAAE,OAAO;AAAA,EACvC,SAASA,GAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACzB,OAAOA,GAAE,MAAMD,qBAAoB;AACrC,CAAC;AAED,IAAMG,wBAAuBF,GAAE,MAAM;AAAA,EACnCD;AAAA,EACAE;AACF,CAAC;;;AHAM,IAAM,cAAc;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAIO,IAAM,iBAAwC;AAAA,EACnD,UAAU;AAAA,EACV,OAAO;AAAA,EACP,UAAU;AAAA,EACV,KAAK;AAAA,EACL,MAAM;AAAA,EACN,MAAM;AAAA,EACN,OAAO;AAAA,EACP,WAAW;AAAA,EACX,UAAU;AAAA,EACV,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,KAAK;AAAA,EACL,IAAI;AACN;AA+zBA,IAAM,wBAA+C;AAAA,EACnD,UAAU,KAAK;AAAA,IACb;AAAA,MACE,SAAS;AAAA,MACT,aAAa;AAAA,MACb,aAAa;AAAA,QACX,MAAM;AAAA,QACN,aAAa;AAAA,QACb,SAAS;AAAA,QACT,eAAe;AAAA,QACf,aAAa;AAAA,MACf;AAAA,MACA,gBAAgB;AAAA,QACd,WAAW;AAAA,QACX,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,GAAG,GAAG,IAAI,IAAI,IAAI,IAAI,EAAE,EAAE;AAAA,QAC9D,gBAAgB,EAAE,SAAS,WAAW,QAAQ,UAAU;AAAA,QACxD,YAAY;AAAA,UACV,UAAU;AAAA,UACV,aAAa;AAAA,UACb,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,YAAY;AAAA,QACd;AAAA,QACA,eAAe;AAAA,QACf,cAAc;AAAA,QACd,iBAAiB;AAAA,MACnB;AAAA,MACA,iBAAiB;AAAA,QACf,OAAO;AAAA,UACL,YAAY;AAAA,UACZ,YAAY;AAAA,UACZ,SAAS;AAAA,UACT,SAAS;AAAA,UACT,QAAQ;AAAA,QACV;AAAA,QACA,MAAM;AAAA,UACJ,YAAY;AAAA,UACZ,YAAY;AAAA,UACZ,SAAS;AAAA,UACT,SAAS;AAAA,UACT,QAAQ;AAAA,QACV;AAAA,MACF;AAAA,MACA,YAAY;AAAA,MACZ,kBAAkB,CAAC;AAAA,IACrB;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EAEA,OAAO,KAAK;AAAA,IACV;AAAA,MACE,SAAS;AAAA,MACT,aAAa;AAAA,MACb,kBAAkB;AAAA,MAClB,aAAa;AAAA,MACb,QAAQ;AAAA,QACN,QAAQ,EAAE,eAAe,WAAW,YAAY,UAAU;AAAA,QAC1D,SAAS,EAAE,aAAa,OAAO,aAAa,OAAO;AAAA,QACnD,YAAY,EAAE,aAAa,SAAS,WAAW,OAAO;AAAA,QACtD,OAAO,EAAE,aAAa,OAAO,aAAa,MAAM;AAAA,QAChD,SAAS,EAAE,aAAa,6BAA6B;AAAA,MACvD;AAAA,MACA,cAAc;AAAA,QACZ,gBAAgB;AAAA,QAChB,gBAAgB;AAAA,QAChB,aAAa;AAAA,QACb,wBAAwB;AAAA,QACxB,aAAa;AAAA,QACb,YAAY;AAAA,MACd;AAAA,MACA,MAAM,EAAE,gBAAgB,kBAAkB,gBAAgB,cAAc;AAAA,IAC1E;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EAEA,UAAU,KAAK;AAAA,IACb;AAAA,MACE,SAAS;AAAA,MACT,aAAa;AAAA;AAAA,MAEb,gBAAgB;AAAA,QACd;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,MACA,OAAO;AAAA,MACP,wBAAwB;AAAA,MACxB,eAAe;AAAA,IACjB;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EAEA,KAAK,KAAK;AAAA,IACR;AAAA,MACE,SAAS;AAAA,MACT,aAAa;AAAA,MACb,UAAU;AAAA,QACR;AAAA,UACE,MAAM;AAAA,UACN,UAAU;AAAA,UACV,QAAQ;AAAA,UACR,YAAY;AAAA,UACZ,cAAc;AAAA,QAChB;AAAA,MACF;AAAA,MACA,SAAS;AAAA,QACP;AAAA,UACE,MAAM;AAAA,UACN,QAAQ;AAAA,UACR,QAAQ;AAAA,QACV;AAAA,MACF;AAAA,MACA,UAAU;AAAA,QACR;AAAA,MACF;AAAA,MACA,MAAM,EAAE,MAAM,UAAU,QAAQ,iBAAiB,QAAQ,YAAY;AAAA,MACrE,SAAS;AAAA,MACT,UAAU;AAAA,IACZ;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EAEA,MAAM,KAAK;AAAA,IACT;AAAA,MACE,SAAS;AAAA,MACT,aAAa;AAAA,MACb,UAAU;AAAA,MACV,WAAW;AAAA,MACX,aAAa,CAAC,EAAE,MAAM,aAAa,YAAY,IAAI,OAAO,MAAM,CAAC;AAAA,MACjE,WAAW,EAAE,UAAU,CAAC,eAAe,GAAG,UAAU,CAAC,WAAW,EAAE;AAAA,MAClE,kBAAkB,EAAE,cAAc,CAAC,GAAG,aAAa,CAAC,EAAE;AAAA,IACxD;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EAEA,KAAK,KAAK;AAAA,IACR;AAAA,MACE,SAAS;AAAA,MACT,aAAa;AAAA,MACb,gBAAgB;AAAA,QACd;AAAA,UACE,YAAY;AAAA,UACZ,UAAU;AAAA,UACV,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,YAAY;AAAA,QACd;AAAA,MACF;AAAA,MACA,OAAO;AAAA,QACL;AAAA,UACE,MAAM;AAAA,UACN,MAAM;AAAA,UACN,aAAa,CAAC,SAAS;AAAA,UACvB,WAAW;AAAA,QACb;AAAA,MACF;AAAA,IACF;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EAEA,UAAU,KAAK;AAAA,IACb;AAAA,MACE,SAAS;AAAA,MACT,aAAa;AAAA;AAAA,MAEb,UAAU;AAAA,QACR,IAAI;AAAA,QACJ,OAAO;AAAA,QACP,OAAO,CAAC,oCAAoC;AAAA,QAC5C,qBAAqB,CAAC,wBAAwB;AAAA,QAC9C,UAAU,CAAC;AAAA,MACb;AAAA,IACF;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EAEA,UAAU,KAAK;AAAA,IACb;AAAA,MACE,SAAS;AAAA,MACT,aAAa;AAAA,MACb,OAAO;AAAA,QACL;AAAA,UACE,MAAM;AAAA,UACN,SAAS;AAAA,UACT,OAAO;AAAA,UACP,YAAY;AAAA,QACd;AAAA,MACF;AAAA,MACA,WAAW;AAAA,QACT;AAAA,UACE,MAAM;AAAA,UACN,QAAQ;AAAA,UACR,aAAa;AAAA,QACf;AAAA,MACF;AAAA,MACA,cAAc,CAAC,+BAA+B;AAAA,MAC9C,kBAAkB,CAAC,gBAAgB,mBAAmB,mBAAmB;AAAA,IAC3E;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EAEA,OAAO,KAAK;AAAA,IACV;AAAA,MACE,SAAS;AAAA,MACT,aAAa;AAAA,MACb,OAAO;AAAA,QACL;AAAA,UACE,MAAM;AAAA,UACN,MAAM;AAAA,UACN,YAAY;AAAA,UACZ,cAAc;AAAA,UACd,cAAc;AAAA,QAChB;AAAA,QACA;AAAA,UACE,MAAM;AAAA,UACN,MAAM;AAAA,UACN,YAAY;AAAA,UACZ,cAAc;AAAA,UACd,cAAc;AAAA,QAChB;AAAA,MACF;AAAA,IACF;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EAEA,WAAW,KAAK;AAAA,IACd;AAAA,MACE,SAAS;AAAA,MACT,aAAa;AAAA,MACb,OAAO;AAAA,QACL;AAAA,UACE,MAAM;AAAA,UACN,QAAQ;AAAA,UACR,aAAa,CAAC,aAAa,UAAU;AAAA,UACrC,MAAM;AAAA,QACR;AAAA,MACF;AAAA,IACF;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA;AAAA;AAAA,EAIA,MAAM,KAAK;AAAA,IACT;AAAA,MACE,SAAS;AAAA,MACT,aAAa;AAAA,MACb,YAAY;AAAA,QACV,MAAM;AAAA;AAAA,QAEN,UAAU;AAAA,QACV,cAAc;AAAA,QACd,UAAU;AAAA,QACV,cAAc;AAAA,QACd,WAAW,CAAC,SAAS,SAAS;AAAA,QAC9B,iBAAiB;AAAA,QACjB,iBAAiB,CAAC,qBAAqB,qBAAqB;AAAA,QAC5D,cAAc;AAAA,QACd,oBAAoB;AAAA,MACtB;AAAA;AAAA;AAAA,MAGA,YAAY;AAAA,QACV,SAAS;AAAA,QACT,SAAS,EAAE,aAAa,2BAA2B;AAAA,MACrD;AAAA,IACF;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EAEA,QAAQ,KAAK;AAAA,IACX;AAAA,MACE,SAAS;AAAA,MACT,aAAa;AAAA,MACb,aAAa,EAAE,MAAM,IAAI,WAAW,KAAK;AAAA,MACzC,YAAY,EAAE,WAAW,GAAG,OAAO,CAAC,cAAc,cAAc,YAAY,EAAE;AAAA,MAC9E,gBAAgB;AAAA,MAChB,iBAAiB;AAAA,QACf,SAAS,CAAC,oCAAoC;AAAA,QAC9C,WAAW,CAAC,mBAAmB;AAAA,QAC/B,SAAS,CAAC;AAAA,MACZ;AAAA,IACF;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EAEA,IAAI,KAAK;AAAA,IACP;AAAA,MACE,SAAS;AAAA,MACT,aAAa;AAAA;AAAA;AAAA,MAGb,SAAS;AAAA,MACT,YAAY;AAAA,MACZ,WAAW;AAAA,MACX,SAAS;AAAA,QACP,eAAe;AAAA,QACf,SAAS;AAAA,QACT,UAAU;AAAA,QACV,OAAO;AAAA,MACT;AAAA,MACA,UAAU;AAAA,QACR;AAAA,UACE,IAAI;AAAA,UACJ,OAAO;AAAA,UACP,UAAU;AAAA,UACV,UAAU;AAAA,UACV,SAAS;AAAA,UACT,UAAU;AAAA,YACR,YAAY;AAAA,YACZ,eAAe,CAAC;AAAA,YAChB,eAAe,CAAC;AAAA,UAClB;AAAA,UACA,kBAAkB,CAAC,6BAA6B;AAAA,UAChD,eAAe;AAAA,QACjB;AAAA,MACF;AAAA,IACF;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;","names":["z","z","authConfigSchema","z","WorkflowStepSchema","WorkflowDefinitionSchema","WorkflowFieldSchema","WorkflowActionSchema","authConfigSchema","z","z","NavigationLinkSchema","z","NavigationSectionSchema","NavigationItemSchema"]}
1
+ {"version":3,"sources":["../src/tools/pipeline.ts","../src/coerce.ts","../src/artifact-signing.ts","../src/tools/get-schema.ts","../src/tools/validate-yaml-fragment.ts"],"sourcesContent":["/**\n * Pipeline State & Artifact Management Tools — \"Sinks Not Pipes\"\n *\n * The filesystem IS the state machine. Every tool reads/writes to `.stackwright/`.\n * Specialists produce artifacts → validated here. Retry logic lives HERE.\n */\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\nimport { readFileSync, writeFileSync, existsSync, mkdirSync, lstatSync } from 'fs';\nimport { join } from 'path';\nimport { parseLLMQuestionsResponse } from '../question-adapter.js';\nimport { boolCoerce, jsonCoerce } from '../coerce.js';\nimport { createHash } from 'crypto';\nimport {\n initPipelineKeys,\n loadPipelineKeys,\n signArtifact,\n verifyArtifact,\n getArtifactSignature,\n saveArtifactSignature,\n emitSignatureAuditEvent,\n loadSignatureManifest,\n} from '../artifact-signing.js';\nimport { SAFE_PHASE } from '../validation.js';\n\n// ─── Canonical pro type schemas for artifact validation ─────────────────────\nimport { WorkflowFileSchema, authConfigSchema } from '@stackwright-pro/types';\nimport { buildSchemaReferenceBlock } from './get-schema.js';\nimport { loadPipelineGraph } from './pipeline-graph.js';\nimport { emit } from '@stackwright-pro/telemetry';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/**\n * Canonical phase execution order — must remain a valid topological sort of\n * the otter pipeline declarations. Validated at server startup by server.ts.\n *\n * Wave decomposition (derived from loadPipelineGraph()):\n * Wave 1: designer, api (no deps)\n * Wave 2: theme, auth, data (designer or api only)\n * Wave 3: scaffold, workflow, geo (theme / theme+api / data)\n * Wave 4: services (api + workflow)\n * Wave 5: pages, dashboard (all above)\n * Wave 6: polish (pages + dashboard + workflow + auth)\n * Wave 7: qa (depends on polish + transitively everything)\n *\n * Key change from the old hardcoded PHASE_DEPENDENCIES:\n * auth: was wave 4 (after pages/dashboard/workflow/geo); now wave 2 (needs designer only)\n * workflow: was wave 1 (no deps); now wave 3 (needs theme + api)\n */\nexport const PHASE_ORDER = [\n 'designer',\n 'theme',\n 'scaffold', // generates app/ directory from config\n 'api',\n 'data',\n 'auth', // moved earlier — only depends on design-language.json (designer)\n 'geo',\n 'workflow',\n 'services',\n 'pages',\n 'dashboard',\n 'polish',\n 'qa',\n] as const;\nexport type Phase = (typeof PHASE_ORDER)[number];\n\n/** Maps phase → expected artifact filename */\nexport const PHASE_ARTIFACT: Record<Phase, string> = {\n designer: 'design-language.json',\n theme: 'theme-tokens.json',\n scaffold: 'scaffold-manifest.json',\n api: 'api-config.json',\n auth: 'auth-config.json',\n data: 'data-config.json',\n pages: 'pages-manifest.json',\n dashboard: 'dashboard-manifest.json',\n workflow: 'workflow-config.json',\n services: 'services-config.json',\n polish: 'polish-manifest.json',\n geo: 'geo-manifest.json',\n qa: 'qa-findings.json',\n};\n\n/** Maps phase → otter agent name */\nconst PHASE_TO_OTTER: Record<Phase, string> = {\n designer: 'stackwright-pro-designer-otter',\n theme: 'stackwright-pro-theme-otter',\n scaffold: 'stackwright-pro-scaffold-otter',\n api: 'stackwright-pro-api-otter',\n auth: 'stackwright-pro-auth-otter',\n data: 'stackwright-pro-data-otter',\n pages: 'stackwright-pro-page-otter',\n dashboard: 'stackwright-pro-dashboard-otter',\n workflow: 'stackwright-pro-form-wizard-otter',\n services: 'stackwright-services-otter',\n polish: 'stackwright-pro-polish-otter',\n geo: 'stackwright-pro-geo-otter',\n qa: 'stackwright-pro-qa-otter',\n};\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface PhaseStatus {\n questionsCollected: boolean;\n answered: boolean;\n executed: boolean;\n artifactWritten: boolean;\n retryCount: number;\n}\n\nexport interface PipelineState {\n version: '1.0';\n currentPhase: string;\n status: 'setup' | 'questions' | 'execution' | 'done';\n phases: Record<string, PhaseStatus>;\n startedAt: string;\n updatedAt: string;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction isValidPhase(phase: string): phase is Phase {\n return (PHASE_ORDER as readonly string[]).includes(phase);\n}\n\nfunction defaultPhaseStatus(): PhaseStatus {\n return {\n questionsCollected: false,\n answered: false,\n executed: false,\n artifactWritten: false,\n retryCount: 0,\n };\n}\n\nfunction createDefaultState(): PipelineState {\n const now = new Date().toISOString();\n const phases: Record<string, PhaseStatus> = {};\n for (const p of PHASE_ORDER) {\n phases[p] = defaultPhaseStatus();\n }\n return {\n version: '1.0',\n currentPhase: PHASE_ORDER[0],\n status: 'setup',\n phases,\n startedAt: now,\n updatedAt: now,\n };\n}\n\nfunction statePath(cwd: string): string {\n return join(cwd, '.stackwright', 'pipeline-state.json');\n}\n\nfunction readState(cwd: string): PipelineState {\n const p = statePath(cwd);\n if (!existsSync(p)) return createDefaultState();\n const raw = JSON.parse(safeReadSync(p));\n // Minimal schema validation — reject corrupted state files\n if (typeof raw !== 'object' || raw === null || raw.version !== '1.0') {\n return createDefaultState();\n }\n return raw as PipelineState;\n}\n\n/**\n * Safely write to a path, rejecting symlinks.\n * Used by all pipeline write operations.\n */\nfunction safeWriteSync(filePath: string, content: string): void {\n if (existsSync(filePath)) {\n const stat = lstatSync(filePath);\n if (stat.isSymbolicLink()) {\n throw new Error(`Refusing to write to symlink: ${filePath}`);\n }\n }\n writeFileSync(filePath, content);\n}\n\n/**\n * Safely read from a path, rejecting symlinks.\n * Used by all pipeline read operations to prevent TOCTOU-based symlink attacks.\n */\nfunction safeReadSync(filePath: string): string {\n if (existsSync(filePath)) {\n const stat = lstatSync(filePath);\n if (stat.isSymbolicLink()) {\n throw new Error(`Refusing to read symlink: ${filePath}`);\n }\n }\n return readFileSync(filePath, 'utf-8');\n}\n\nfunction writeState(cwd: string, state: PipelineState): void {\n const dir = join(cwd, '.stackwright');\n mkdirSync(dir, { recursive: true });\n state.updatedAt = new Date().toISOString();\n safeWriteSync(statePath(cwd), JSON.stringify(state, null, 2) + '\\n');\n}\n\n/** Extract JSON from messy LLM output — handles markdown fences, leading prose */\nfunction extractJsonFromResponse(text: string): unknown {\n let cleaned = text;\n // Strip markdown code fences (```json ... ``` or ``` ... ```)\n cleaned = cleaned.replace(/```(?:json)?\\s*/gi, '');\n cleaned = cleaned.replace(/```\\s*$/gm, '');\n\n // Find the outermost JSON object\n const firstBrace = cleaned.indexOf('{');\n if (firstBrace === -1) throw new Error('No JSON object found in response');\n cleaned = cleaned.substring(firstBrace);\n\n const lastBrace = cleaned.lastIndexOf('}');\n if (lastBrace === -1) throw new Error('Unclosed JSON object in response');\n cleaned = cleaned.substring(0, lastBrace + 1);\n\n // Fix common LLM JSON quirks\n cleaned = cleaned.replace(/,(\\s*[}\\]])/g, '$1'); // trailing commas\n return JSON.parse(cleaned);\n}\n\n// ---------------------------------------------------------------------------\n// Handler functions (exported for direct testing — no MCP server needed)\n// ---------------------------------------------------------------------------\n\nexport function handleGetPipelineState(_cwd?: string): { text: string; isError: boolean } {\n const cwd = _cwd ?? process.cwd();\n try {\n const state = readState(cwd);\n\n // Auto-initialize signing keys on first access (lazy init)\n const keyPath = join(cwd, '.stackwright', 'pipeline-keys.json');\n if (!existsSync(keyPath)) {\n try {\n const { fingerprint } = initPipelineKeys(cwd);\n // Record key fingerprint in pipeline state for audit trail\n (state as unknown as Record<string, unknown>)['signingKeyFingerprint'] = fingerprint;\n writeState(cwd, state);\n } catch {\n // Non-blocking — signing is defense-in-depth, not a hard gate at init\n }\n }\n\n return { text: JSON.stringify(state), isError: false };\n } catch (err) {\n return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };\n }\n}\n\nexport interface BatchStateUpdate {\n phase: string;\n field: 'questionsCollected' | 'answered' | 'executed' | 'artifactWritten';\n value: boolean;\n}\n\nexport interface SetPipelineStateInput {\n phase?: string;\n field?: 'questionsCollected' | 'answered' | 'executed' | 'artifactWritten';\n value?: boolean;\n status?: 'setup' | 'questions' | 'execution' | 'done';\n incrementRetry?: boolean;\n updates?: BatchStateUpdate[];\n _cwd?: string;\n}\n\nexport function handleSetPipelineState(input: SetPipelineStateInput): {\n text: string;\n isError: boolean;\n} {\n const cwd = input._cwd ?? process.cwd();\n\n // Validate phase name if provided\n if (input.phase && !isValidPhase(input.phase)) {\n return {\n text: JSON.stringify({\n error: true,\n message: `Invalid phase: ${input.phase}. Valid phases are: ${PHASE_ORDER.join(', ')}`,\n }),\n isError: true,\n };\n }\n\n // Validate field name if provided\n const VALID_FIELDS = ['questionsCollected', 'answered', 'executed', 'artifactWritten'] as const;\n if (input.field && !VALID_FIELDS.includes(input.field as (typeof VALID_FIELDS)[number])) {\n return {\n text: JSON.stringify({\n error: true,\n message: `Invalid field: ${input.field}. Valid fields are: ${VALID_FIELDS.join(', ')}`,\n }),\n isError: true,\n };\n }\n\n // Validate batch updates before touching state\n if (input.updates && input.updates.length > 0) {\n for (const update of input.updates) {\n if (!isValidPhase(update.phase)) {\n return {\n text: JSON.stringify({\n error: true,\n message: `Invalid phase in batch update: ${update.phase}. Valid phases are: ${PHASE_ORDER.join(', ')}`,\n }),\n isError: true,\n };\n }\n if (!VALID_FIELDS.includes(update.field as (typeof VALID_FIELDS)[number])) {\n return {\n text: JSON.stringify({\n error: true,\n message: `Invalid field in batch update: ${update.field}. Valid fields are: ${VALID_FIELDS.join(', ')}`,\n }),\n isError: true,\n };\n }\n }\n }\n\n try {\n const state = readState(cwd);\n\n // Update top-level status if provided\n if (input.status) {\n state.status = input.status;\n }\n\n // Update phase-level fields\n if (input.phase) {\n const phase = input.phase;\n if (!state.phases[phase]) {\n state.phases[phase] = defaultPhaseStatus();\n }\n const phaseState = state.phases[phase];\n\n if (input.field && input.value !== undefined) {\n phaseState[input.field] = input.value;\n }\n\n if (input.incrementRetry) {\n phaseState.retryCount += 1;\n }\n\n state.currentPhase = phase;\n }\n\n // Apply batch updates in the same read→modify→write cycle\n if (input.updates && input.updates.length > 0) {\n for (const update of input.updates) {\n if (!state.phases[update.phase]) {\n state.phases[update.phase] = defaultPhaseStatus();\n }\n const batchPhaseState = state.phases[update.phase]!;\n batchPhaseState[update.field] = update.value;\n state.currentPhase = update.phase;\n }\n }\n\n writeState(cwd, state);\n\n // Telemetry: best-effort, never blocks (swp-im3c)\n try {\n const completedPhases = Object.entries(state.phases)\n .filter(([, ps]) => ps.artifactWritten)\n .map(([p]) => p);\n emit(\n {\n type: 'pipeline_state_change',\n state: state.status,\n phases: { ready: [], completed: completedPhases, blocked: [] },\n },\n { cwd }\n );\n // If a specific phase just had artifactWritten set to true, emit phase_complete\n if (input.field === 'artifactWritten' && input.value === true && input.phase) {\n emit({ type: 'phase_complete', phase: input.phase }, { cwd });\n }\n // Batch updates: emit phase_complete for any phase that just had artifactWritten set\n if (input.updates) {\n for (const update of input.updates) {\n if (update.field === 'artifactWritten' && update.value === true) {\n emit({ type: 'phase_complete', phase: update.phase }, { cwd });\n }\n }\n }\n } catch {\n // Telemetry errors never surface to callers\n }\n\n return { text: JSON.stringify(state), isError: false };\n } catch (err) {\n return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };\n }\n}\n\nexport function handleCheckExecutionReady(\n _cwd?: string,\n phase?: string\n): { text: string; isError: boolean } {\n const cwd = _cwd ?? process.cwd();\n\n if (phase) {\n if (!isValidPhase(phase)) {\n return {\n text: JSON.stringify({\n error: true,\n message: `Invalid phase: ${phase}. Valid phases are: ${PHASE_ORDER.join(', ')}`,\n }),\n isError: true,\n };\n }\n const answerFile = join(cwd, '.stackwright', 'answers', `${phase}.json`);\n if (!existsSync(answerFile)) {\n return {\n text: JSON.stringify({ ready: false, phase, reason: 'Answer file not found' }),\n isError: false,\n };\n }\n try {\n const raw = safeReadSync(answerFile);\n const parsed = JSON.parse(raw) as Record<string, unknown>;\n if (\n typeof parsed['version'] !== 'string' ||\n typeof parsed['phase'] !== 'string' ||\n typeof parsed['answers'] !== 'object' ||\n parsed['answers'] === null\n ) {\n return {\n text: JSON.stringify({ ready: false, phase, reason: 'Answer file is malformed' }),\n isError: false,\n };\n }\n return { text: JSON.stringify({ ready: true, phase }), isError: false };\n } catch {\n return {\n text: JSON.stringify({ ready: false, phase, reason: 'Answer file could not be parsed' }),\n isError: false,\n };\n }\n }\n\n try {\n const answersDir = join(cwd, '.stackwright', 'answers');\n const answeredPhases: string[] = [];\n const missingPhases: string[] = [];\n\n for (const phase of PHASE_ORDER) {\n const answerFile = join(answersDir, `${phase}.json`);\n if (existsSync(answerFile)) {\n // Validate: file must be parseable JSON with required fields\n try {\n const raw = safeReadSync(answerFile);\n const parsed = JSON.parse(raw) as Record<string, unknown>;\n\n // Must have version, phase, and answers keys\n if (\n typeof parsed['version'] !== 'string' ||\n typeof parsed['phase'] !== 'string' ||\n typeof parsed['answers'] !== 'object' ||\n parsed['answers'] === null\n ) {\n // File exists but is malformed — treat as missing\n missingPhases.push(phase);\n continue;\n }\n\n answeredPhases.push(phase);\n } catch {\n // Can't parse — treat as missing\n missingPhases.push(phase);\n }\n } else {\n missingPhases.push(phase);\n }\n }\n\n return {\n text: JSON.stringify({\n ready: missingPhases.length === 0,\n answeredPhases,\n missingPhases,\n totalPhases: PHASE_ORDER.length,\n }),\n isError: false,\n };\n } catch (err) {\n return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };\n }\n}\n\n/**\n * Return phases whose dependencies are all satisfied (executed === true in\n * pipeline state). The foreman calls this in a loop to discover which phases\n * can run next — enabling wave-based execution instead of strict serial order.\n *\n * Wave decomposition (derived from otter pipeline declarations — see PHASE_ORDER):\n * Wave 1: designer, api\n * Wave 2: theme, auth, data\n * Wave 3: scaffold, workflow, geo\n * Wave 4: services\n * Wave 5: pages, dashboard\n * Wave 6: polish\n */\nexport function handleGetReadyPhases(_cwd?: string): { text: string; isError: boolean } {\n const cwd = _cwd ?? process.cwd();\n try {\n const state = readState(cwd);\n const graph = loadPipelineGraph();\n\n const completed: string[] = [];\n const ready: string[] = [];\n const blocked: string[] = [];\n\n for (const phase of PHASE_ORDER) {\n const ps = state.phases[phase];\n if (ps?.executed) {\n completed.push(phase);\n continue;\n }\n // Phase is ready if every dependency is executed\n const deps = graph.dependencies[phase] ?? [];\n const unmetDeps = deps.filter((dep) => !state.phases[dep]?.executed);\n if (unmetDeps.length === 0) {\n ready.push(phase);\n } else {\n blocked.push(phase);\n }\n }\n\n return {\n text: JSON.stringify({\n readyPhases: ready,\n completedPhases: completed,\n blockedPhases: blocked,\n waveSize: ready.length,\n allComplete: ready.length === 0 && blocked.length === 0,\n }),\n isError: false,\n };\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n return { text: JSON.stringify({ error: true, message }), isError: true };\n }\n}\n\nexport function handleListArtifacts(_cwd?: string): { text: string; isError: boolean } {\n const cwd = _cwd ?? process.cwd();\n try {\n const artifactsDir = join(cwd, '.stackwright', 'artifacts');\n\n // Load signature manifest (non-blocking if missing)\n let manifest: ReturnType<typeof loadSignatureManifest> | null = null;\n try {\n manifest = loadSignatureManifest(cwd);\n } catch {\n // No signature manifest — report unsigned\n }\n\n const artifacts: Array<{\n phase: string;\n expectedFile: string;\n exists: boolean;\n path: string;\n signed: boolean;\n signatureValid: boolean | null;\n }> = [];\n let completedCount = 0;\n\n for (const phase of PHASE_ORDER) {\n const expectedFile = PHASE_ARTIFACT[phase];\n const fullPath = join(artifactsDir, expectedFile);\n const exists = existsSync(fullPath);\n if (exists) completedCount++;\n\n // Check signature status\n let signed = false;\n let signatureValid: boolean | null = null;\n\n if (exists && manifest) {\n const entry = manifest.signatures[expectedFile];\n if (entry) {\n signed = true;\n // Verify if keys are available\n try {\n const rawBytes = Buffer.from(safeReadSync(fullPath), 'utf-8');\n const { publicKey } = loadPipelineKeys(cwd);\n signatureValid = verifyArtifact(rawBytes, entry, publicKey);\n } catch {\n signatureValid = null; // Can't verify — keys unavailable\n }\n }\n }\n\n artifacts.push({ phase, expectedFile, exists, path: fullPath, signed, signatureValid });\n }\n\n return {\n text: JSON.stringify({ artifacts, completedCount, totalCount: PHASE_ORDER.length }),\n isError: false,\n };\n } catch (err) {\n return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };\n }\n}\n\nexport interface WritePhaseQuestionsInput {\n phase: string;\n responseText: string;\n _cwd?: string;\n}\n\nexport function handleWritePhaseQuestions(input: WritePhaseQuestionsInput): {\n text: string;\n isError: boolean;\n} {\n const cwd = input._cwd ?? process.cwd();\n const { phase, responseText } = input;\n\n if (!isValidPhase(phase)) {\n return {\n text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),\n isError: true,\n };\n }\n\n try {\n const questions = parseLLMQuestionsResponse(responseText);\n\n // Extract requiredPackages if present in the raw response\n let requiredPackages: {\n dependencies: Record<string, string>;\n devPackages: Record<string, string>;\n } = {\n dependencies: {},\n devPackages: {},\n };\n try {\n const fullParsed = extractJsonFromResponse(responseText) as Record<string, unknown>;\n if (fullParsed.requiredPackages && typeof fullParsed.requiredPackages === 'object') {\n const rp = fullParsed.requiredPackages as Record<string, unknown>;\n requiredPackages = {\n dependencies: (rp.dependencies as Record<string, string>) ?? {},\n devPackages: (rp.devPackages as Record<string, string>) ?? {},\n };\n }\n } catch {\n // extractJsonFromResponse may fail if the response is a bare array — that's fine\n }\n\n const questionsDir = join(cwd, '.stackwright', 'questions');\n mkdirSync(questionsDir, { recursive: true });\n const filePath = join(questionsDir, `${phase}.json`);\n\n const payload = {\n version: '1.0',\n phase,\n otter: PHASE_TO_OTTER[phase],\n collectedAt: new Date().toISOString(),\n questions,\n requiredPackages,\n };\n\n safeWriteSync(filePath, JSON.stringify(payload, null, 2) + '\\n');\n\n // Update pipeline state\n const state = readState(cwd);\n if (!state.phases[phase]) state.phases[phase] = defaultPhaseStatus();\n const ps = state.phases[phase]!; // guaranteed by line above\n ps.questionsCollected = true;\n writeState(cwd, state);\n\n return {\n text: JSON.stringify({\n success: true,\n phase,\n questionCount: questions.length,\n requiredPackages,\n path: filePath,\n }),\n isError: false,\n };\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n return { text: JSON.stringify({ error: true, phase, message }), isError: true };\n }\n}\n\nexport interface BuildSpecialistPromptInput {\n phase: string;\n _cwd?: string;\n}\n\nexport function handleBuildSpecialistPrompt(input: BuildSpecialistPromptInput): {\n text: string;\n isError: boolean;\n} {\n const cwd = input._cwd ?? process.cwd();\n const { phase } = input;\n\n if (!isValidPhase(phase)) {\n return {\n text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),\n isError: true,\n };\n }\n\n try {\n // 1. Read answers for this phase\n const answersPath = join(cwd, '.stackwright', 'answers', `${phase}.json`);\n let answers: unknown = {};\n if (existsSync(answersPath)) {\n answers = JSON.parse(safeReadSync(answersPath));\n }\n\n // 1b. Read build context (optional — non-blocking if missing or malformed)\n let buildContextText = '';\n const buildContextPath = join(cwd, '.stackwright', 'build-context.json');\n if (existsSync(buildContextPath)) {\n try {\n const bcRaw = JSON.parse(safeReadSync(buildContextPath)) as { buildContext?: string };\n if (typeof bcRaw.buildContext === 'string' && bcRaw.buildContext.trim().length > 0) {\n buildContextText = bcRaw.buildContext.trim();\n }\n } catch {\n // Non-blocking — missing or malformed build context does not block execution\n }\n }\n\n // 2. Read upstream dependency artifacts\n const graph = loadPipelineGraph();\n const deps = graph.dependencies[phase] ?? [];\n const artifactSections: string[] = [];\n const missingDependencies: string[] = [];\n\n for (const dep of deps) {\n const artifactFile = PHASE_ARTIFACT[dep as Phase];\n const artifactPath = join(cwd, '.stackwright', 'artifacts', artifactFile);\n if (existsSync(artifactPath)) {\n const rawContent = safeReadSync(artifactPath);\n const rawBytes = Buffer.from(rawContent, 'utf-8');\n const content = JSON.parse(rawContent) as Record<string, unknown>;\n\n // ── Cryptographic signature verification (primary integrity check) ──\n // If signing keys exist, verify the ECDSA P-384 signature before trusting\n // the artifact. Falls back to generatedBy check if unsigned (backwards compat).\n let signatureVerified = false;\n let signatureAvailable = false;\n\n try {\n const sig = getArtifactSignature(cwd, artifactFile);\n if (sig) {\n signatureAvailable = true;\n const { publicKey } = loadPipelineKeys(cwd);\n signatureVerified = verifyArtifact(rawBytes, sig, publicKey);\n\n if (!signatureVerified) {\n // Emit audit event for SOC2/FedRAMP compliance\n const actualDigest = createHash('sha384').update(rawBytes).digest('hex');\n emitSignatureAuditEvent({\n artifactFilename: artifactFile,\n expectedDigest: sig.digest,\n actualDigest,\n phase: dep,\n timestamp: new Date().toISOString(),\n source: 'stackwright_pro_build_specialist_prompt',\n });\n\n missingDependencies.push(dep);\n artifactSections.push(\n `[${artifactFile}]:\\n(integrity check failed: ECDSA-P384 signature verification failed — artifact may have been tampered with)`\n );\n continue;\n }\n }\n } catch {\n // Key loading failed — fall through to generatedBy check\n }\n\n // ── Secondary check: generatedBy field cross-validation ──\n // Even with valid signatures, verify the generatedBy field matches expectations.\n // Without signatures (unsigned pipeline), this is the primary check.\n const expectedOtter = PHASE_TO_OTTER[dep as Phase];\n const artifactOtter = content['generatedBy'] as string | undefined;\n const normalizedOtter = artifactOtter?.replace(/-[a-f0-9]{6}$/, '');\n\n if (!artifactOtter) {\n missingDependencies.push(dep);\n artifactSections.push(\n `[${artifactFile}]:\\n(integrity check failed: missing generatedBy field)`\n );\n } else if (normalizedOtter !== expectedOtter) {\n missingDependencies.push(dep);\n artifactSections.push(\n `[${artifactFile}]:\\n(integrity check failed: artifact claims generatedBy=\"${artifactOtter}\" but expected=\"${expectedOtter}\")`\n );\n } else {\n const sigStatus = signatureAvailable\n ? signatureVerified\n ? ' [signature verified]'\n : ' [signature check skipped]'\n : ' [unsigned]';\n artifactSections.push(\n `[${artifactFile}]${sigStatus}:\\n${JSON.stringify(content, null, 2)}`\n );\n }\n } else {\n missingDependencies.push(dep);\n artifactSections.push(`[${artifactFile}]:\\n(not yet available)`);\n }\n }\n\n // 3. Assemble the prompt\n const parts: string[] = [];\n\n if (buildContextText) {\n parts.push('BUILD_CONTEXT:', buildContextText, '');\n }\n\n // For auth phase: inject devOnly flag from init-context so the auth otter\n // always passes it to configure_auth — prevents prebuild crash (env var refs in dev mode)\n if (phase === 'auth') {\n const initContextPath = join(cwd, '.stackwright', 'init-context.json');\n if (existsSync(initContextPath)) {\n try {\n const initContext = JSON.parse(safeReadSync(initContextPath));\n if (initContext.devOnly === true || initContext.nonInteractive === true) {\n (answers as Record<string, unknown>)['devOnly'] = true;\n }\n } catch {\n // Non-blocking — auth otter can still infer from BUILD_CONTEXT\n }\n }\n }\n\n parts.push('ANSWERS:', JSON.stringify(answers, null, 2));\n\n if (artifactSections.length > 0) {\n parts.push('', 'UPSTREAM ARTIFACTS:', '', ...artifactSections);\n }\n\n // Inject the canonical artifact schema so specialists always build against\n // the current contract — not hardcoded examples in their JSON files.\n const artifactSchema = PHASE_ARTIFACT_SCHEMA[phase];\n parts.push('', 'REQUIRED_ARTIFACT_SCHEMA:');\n parts.push(artifactSchema);\n\n // Inject canonical field-name schema reference for phases that produce\n // YAML with strict schemas (workflow, auth, pages/navigation). This closes\n // the schema-prompt impedance mismatch identified across 5 postmortems —\n // specialists receive the actual Zod schema, not a prose copy that can drift.\n const schemaRef = buildSchemaReferenceBlock(phase);\n if (schemaRef) {\n parts.push('', schemaRef);\n }\n\n parts.push('', 'Execute using these answers and the upstream artifacts provided.');\n\n const prompt = parts.join('\\n');\n const dependenciesSatisfied = missingDependencies.length === 0;\n\n return {\n text: JSON.stringify({\n otterName: PHASE_TO_OTTER[phase],\n phase,\n prompt,\n dependenciesSatisfied,\n missingDependencies,\n }),\n isError: false,\n };\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n return { text: JSON.stringify({ error: true, phase, message }), isError: true };\n }\n}\n\nexport interface ValidateArtifactInput {\n phase: string;\n responseText?: string;\n artifact?: Record<string, unknown>;\n _cwd?: string;\n}\n\nconst OFF_SCRIPT_PATTERNS: Array<{ pattern: RegExp; label: string }> = [\n {\n pattern: /```(?:ts|tsx|js|jsx|python|bash|sh|sql|ruby|go|rust|java|csharp|c\\+\\+)\\b/,\n label: 'code fence',\n },\n { pattern: /\\bimport\\s+[\\w{]/, label: 'import statement' },\n { pattern: /\\bexport\\s+(?:const|function|default|class)\\b/, label: 'export statement' },\n { pattern: /\\brequire\\s*\\(/, label: 'require() call' },\n { pattern: /\\beval\\s*\\(/, label: 'eval() call' },\n { pattern: /^#!/m, label: 'shebang' },\n { pattern: /<script[\\s>]/i, label: 'script tag' },\n { pattern: /\\.(ts|tsx|js|jsx)\\b.*\\bfile\\b/i, label: 'code file reference' },\n];\n\ninterface ValidArtifactResult {\n valid: true;\n phase: string;\n artifactPath: string;\n summary: string;\n}\n\ninterface InvalidArtifactResult {\n valid: false;\n phase: string;\n violation: 'off-script' | 'invalid-json' | 'missing-fields' | 'schema-mismatch' | 'rbac-theatre';\n retryPrompt: string;\n}\n\nconst PHASE_REQUIRED_KEYS: Record<Phase, string[]> = {\n designer: ['designLanguage', 'themeTokenSeeds'],\n theme: ['tokens'],\n scaffold: ['version', 'generatedBy', 'appRouterFiles'],\n api: ['entities', 'version', 'generatedBy'],\n auth: ['version', 'generatedBy'],\n data: ['version', 'generatedBy', 'strategy', 'collections'],\n pages: ['version', 'generatedBy'],\n dashboard: ['version', 'generatedBy'],\n workflow: ['version', 'generatedBy'],\n services: ['version', 'generatedBy', 'flows'],\n polish: ['version', 'generatedBy'],\n geo: ['version', 'generatedBy', 'geoCollections'],\n // qa: skipped=true path only needs version+generatedBy+skipped;\n // skipped=false path also needs summary+findings — Zod validator enforces the conditional.\n qa: ['version', 'generatedBy', 'skipped'],\n};\n\n/**\n * Canonical artifact schema examples for each phase.\n *\n * SINGLE SOURCE OF TRUTH — injected into specialist prompts at invocation time\n * via handleBuildSpecialistPrompt. Both validate_artifact (enforcement) and\n * build_specialist_prompt (guidance) derive from the same constant here.\n *\n * Schema changes happen in THIS file only. Zero drift possible.\n */\nconst PHASE_ARTIFACT_SCHEMA: Record<Phase, string> = {\n designer: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-designer-otter',\n application: {\n type: '<operational|data-explorer|admin|logistics|general>',\n environment: '<workstation|field|control-room|mixed>',\n density: '<compact|balanced|spacious>',\n accessibility: '<wcag-aa|section-508|none>',\n colorScheme: '<light|dark|both>',\n },\n designLanguage: {\n rationale: '<design rationale>',\n spacingScale: { base: 8, scale: [0, 4, 8, 16, 24, 32, 48, 64] },\n colorSemantics: { primary: '#1a365d', accent: '#e53e3e' },\n typography: {\n dataFont: 'Inter',\n headingFont: 'Inter',\n monoFont: 'monospace',\n dataSizePx: 12,\n bodySizePx: 14,\n },\n contrastRatio: '4.5',\n borderRadius: '4',\n shadowElevation: 'standard',\n },\n themeTokenSeeds: {\n light: {\n background: '#ffffff',\n foreground: '#1a1a1a',\n primary: '#1a365d',\n surface: '#f7f7f7',\n border: '#e2e8f0',\n },\n dark: {\n background: '#1a1a1a',\n foreground: '#ffffff',\n primary: '#90cdf4',\n surface: '#2d2d2d',\n border: '#4a5568',\n },\n },\n conformsTo: null,\n operationalNotes: [],\n },\n null,\n 2\n ),\n\n theme: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-theme-otter',\n componentLibrary: 'shadcn',\n colorScheme: '<light|dark|both>',\n tokens: {\n colors: { 'primary-500': '#1a365d', background: '#ffffff' },\n spacing: { 'spacing-1': '8px', 'spacing-2': '16px' },\n typography: { 'font-data': 'Inter', 'text-sm': '12px' },\n shape: { 'radius-sm': '4px', 'radius-md': '8px' },\n shadows: { 'shadow-sm': '0 1px 2px rgba(0,0,0,0.08)' },\n },\n cssVariables: {\n '--background': '0 0% 100%',\n '--foreground': '222.2 84% 4.9%',\n '--primary': '222.2 47.4% 11.2%',\n '--primary-foreground': '210 40% 98%',\n '--surface': '210 40% 98%',\n '--border': '214.3 31.8% 91.4%',\n },\n dark: { '--background': '222.2 84% 4.9%', '--foreground': '210 40% 98%' },\n },\n null,\n 2\n ),\n\n scaffold: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-scaffold-otter',\n // REQUIRED: appRouterFiles is a required key — NOT 'files' (see swp-k3mb)\n appRouterFiles: [\n 'app/layout.tsx',\n 'app/_components/page-client.tsx',\n 'app/page.tsx',\n 'app/[...slug]/page.tsx',\n 'app/_components/providers.tsx',\n 'app/not-found.tsx',\n ],\n title: '<title from stackwright.yml>',\n hasCollectionEndpoints: false,\n hasAuthConfig: true,\n },\n null,\n 2\n ),\n\n api: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-api-otter',\n entities: [\n {\n name: 'Shipment',\n endpoint: '/shipments',\n method: 'GET',\n revalidate: 60,\n mutationType: null,\n },\n ],\n skipped: [\n {\n spec: 'ais-message-models.yaml',\n format: 'asyncapi',\n reason: 'AsyncAPI spec — WebSocket/event integration required (no REST endpoints)',\n },\n ],\n warnings: [\n 'Spec contains external $ref URLs — recommend bundle: true in stackwright.yml integration config',\n ],\n auth: { type: 'bearer', header: 'Authorization', envVar: 'API_TOKEN' },\n baseUrl: 'https://api.example.mil/v2',\n specPath: './specs/api.yaml',\n },\n null,\n 2\n ),\n\n data: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-data-otter',\n strategy: '<pulse-fast|isr-fast|isr-standard|isr-slow>',\n pulseMode: false,\n collections: [{ name: 'equipment', revalidate: 60, pulse: false }],\n endpoints: { included: ['/equipment/**'], excluded: ['/admin/**'] },\n requiredPackages: { dependencies: {}, devPackages: {} },\n },\n null,\n 2\n ),\n\n geo: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-geo-otter',\n geoCollections: [\n {\n collection: 'vessels',\n latField: 'latitude',\n lngField: 'longitude',\n labelField: 'vesselName',\n colorField: 'navigationStatus',\n },\n ],\n pages: [\n {\n slug: 'fleet-tracker',\n type: '<tracker|zone|route|combined>',\n collections: ['vessels'],\n hasLayers: false,\n },\n ],\n },\n null,\n 2\n ),\n\n workflow: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-form-wizard-otter',\n // Root key is `workflow` — NOT `workflowConfig` (see swp-k7cl)\n workflow: {\n id: 'procurement-approval',\n route: '/procurement',\n files: ['workflows/procurement-approval.yml'],\n serviceDependencies: ['service:workflow-state'],\n warnings: [],\n },\n },\n null,\n 2\n ),\n\n services: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-services-otter',\n flows: [\n {\n name: 'at-risk-patients',\n trigger: '<http|event|schedule|queue>',\n steps: 3,\n outputSpec: 'at-risk-patients.openapi.json',\n },\n ],\n workflows: [\n {\n name: 'evacuation-coordination',\n states: 4,\n transitions: 5,\n },\n ],\n openApiSpecs: ['at-risk-patients.openapi.json'],\n capabilitiesUsed: ['service.call', 'collection.join', 'collection.filter'],\n },\n null,\n 2\n ),\n\n pages: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-page-otter',\n pages: [\n {\n slug: 'catalog',\n type: 'collection_listing',\n collection: 'products',\n themeApplied: true,\n authRequired: false,\n },\n {\n slug: 'admin',\n type: 'protected',\n collection: null,\n themeApplied: true,\n authRequired: true,\n },\n ],\n },\n null,\n 2\n ),\n\n dashboard: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-dashboard-otter',\n pages: [\n {\n slug: 'dashboard',\n layout: '<grid|table|mixed>',\n collections: ['equipment', 'supplies'],\n mode: '<ISR|Pulse>',\n },\n ],\n },\n null,\n 2\n ),\n\n // type: 'pki' = CAC/DoD certificate auth | 'oidc' = enterprise SSO\n // For dev-only mock auth: use type: 'oidc' with devOnly: true (Zod strips devOnly — it's a convention only)\n auth: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-auth-otter',\n authConfig: {\n type: '<pki|oidc>',\n // OIDC-only fields (omit for pki):\n provider: '<azure_ad|okta|cognito|auth0|authentik|keycloak|custom>',\n discoveryUrl: '<IdP OIDC discovery URL>',\n clientId: '<OIDC client ID>',\n clientSecret: '<OIDC client secret>',\n rbacRoles: ['ADMIN', 'ANALYST'],\n rbacDefaultRole: 'ANALYST',\n protectedRoutes: ['/dashboard/:path*', '/procurement/:path*'],\n auditEnabled: true,\n auditRetentionDays: 90,\n },\n // devScripts is OPTIONAL — only present when devOnly: true was used\n // Polish otter and foreman MUST check devScripts.written before referencing scripts\n devScripts: {\n written: true,\n scripts: { 'dev:admin': 'MOCK_USER=admin next dev' },\n },\n },\n null,\n 2\n ),\n\n polish: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-polish-otter',\n landingPage: { slug: '', rewritten: true },\n navigation: { itemCount: 5, items: ['/dashboard', '/equipment', '/workflows'] },\n gettingStarted: '<rewritten|redirected|not-found>',\n scaffoldCleanup: {\n deleted: ['content/posts/getting-started.yaml'],\n rewritten: ['app/not-found.tsx'],\n skipped: [],\n },\n },\n null,\n 2\n ),\n\n qa: JSON.stringify(\n {\n version: '1.0',\n generatedBy: 'stackwright-pro-qa-otter',\n // skipped: true path — when dev server is unreachable at audit time\n // skipped: false path — full audit completed\n skipped: false,\n skipReason: null,\n wcagLevel: '<AA|AAA>',\n summary: {\n routesAudited: 3,\n serious: 0,\n moderate: 1,\n minor: 0,\n },\n findings: [\n {\n id: 'qa-001',\n route: '/dashboard',\n severity: '<serious|moderate|minor>',\n category: '<visual|a11y|design-contract|runtime>',\n finding: 'Human-readable description of what was observed',\n evidence: {\n screenshot: '.stackwright/qa/screenshots/dashboard-light.png',\n consoleErrors: [],\n axeViolations: [],\n },\n suggested_otters: ['stackwright-pro-theme-otter'],\n suggested_fix: 'Specific, concrete next step the repair otter should take',\n },\n ],\n },\n null,\n 2\n ),\n};\n\nfunction _validateArtifactInner(input: ValidateArtifactInput): {\n text: string;\n isError: boolean;\n} {\n const cwd = input._cwd ?? process.cwd();\n const { phase, responseText, artifact: directArtifact } = input;\n\n if (!isValidPhase(phase)) {\n return {\n text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),\n isError: true,\n };\n }\n\n let artifact: Record<string, unknown>;\n\n if (directArtifact) {\n // Direct artifact path (specialist calling with artifact object):\n // Skip off-script detection and JSON string parsing — already an object.\n artifact = directArtifact;\n } else {\n // Legacy responseText path (Foreman-mediated):\n const text = responseText ?? '';\n\n // 1. Off-script detection — check the RAW response before trying to parse\n for (const { pattern, label } of OFF_SCRIPT_PATTERNS) {\n if (pattern.test(text)) {\n const result: InvalidArtifactResult = {\n valid: false,\n phase,\n violation: 'off-script',\n retryPrompt: `You returned code output (detected: ${label}). Return ONLY a JSON artifact — no code, no files.`,\n };\n return { text: JSON.stringify(result), isError: false };\n }\n }\n\n // 2. JSON extraction\n try {\n artifact = extractJsonFromResponse(text) as Record<string, unknown>;\n } catch {\n const result: InvalidArtifactResult = {\n valid: false,\n phase,\n violation: 'invalid-json',\n retryPrompt:\n 'Your response did not contain valid JSON. Return a single JSON object with no surrounding text.',\n };\n return { text: JSON.stringify(result), isError: false };\n }\n }\n\n // 3. Required universal fields\n if (!artifact.version || !artifact.generatedBy) {\n const result: InvalidArtifactResult = {\n valid: false,\n phase,\n violation: 'missing-fields',\n retryPrompt:\n 'Your JSON artifact is missing required fields. Every artifact MUST include \"version\" and \"generatedBy\" at the top level.',\n };\n return { text: JSON.stringify(result), isError: false };\n }\n\n // 4. Phase-specific key checks\n const requiredKeys = PHASE_REQUIRED_KEYS[phase];\n const missingKeys = requiredKeys.filter((k) => !(k in artifact));\n if (missingKeys.length > 0) {\n const result: InvalidArtifactResult = {\n valid: false,\n phase,\n violation: 'schema-mismatch',\n retryPrompt: `Your ${phase} artifact is missing required keys: ${missingKeys.join(', ')}. Include them and try again.`,\n };\n return { text: JSON.stringify(result), isError: false };\n }\n\n // 5. Zod schema validation (where canonical schemas are available)\n // Provides richer error messages than key-presence checks alone.\n const PHASE_ZOD_VALIDATORS: Partial<\n Record<\n Phase,\n (artifact: Record<string, unknown>) => { success: boolean; error?: { message: string } }\n >\n > = {\n workflow: (artifact) => {\n // The workflow otter produces { version, generatedBy, workflow: { id, route, files, ... } }\n // Primary key: 'workflow'. Defensive: accept 'workflowConfig' as a deprecated alias.\n const workflowData = artifact['workflow'];\n if (!workflowData) {\n if (artifact['workflowConfig']) {\n // swp-k7cl: 'workflowConfig' is the old key name; warn and accept so the run\n // still succeeds while the otter prompt change propagates.\n console.warn(\n '[pipeline] DEPRECATED: workflow artifact uses \"workflowConfig\" key; rename to \"workflow\"'\n );\n return { success: true };\n }\n // No workflow/workflowConfig key — fine, PHASE_REQUIRED_KEYS only checks version/generatedBy\n return { success: true };\n }\n // Optionally deep-validate if the artifact contains the full workflow file content\n // (i.e., the otter embedded { workflow: { id, label, steps } } in the workflow key).\n // Skip if the value is plain metadata (no nested 'workflow' key).\n if (typeof workflowData === 'object' && workflowData !== null && 'workflow' in workflowData) {\n const result = WorkflowFileSchema.safeParse(workflowData);\n if (!result.success) {\n const issues = result.error.issues\n .slice(0, 3)\n .map((i) => `${i.path.join('.')}: ${i.message}`)\n .join('; ');\n return { success: false, error: { message: issues } };\n }\n }\n return { success: true };\n },\n auth: (artifact) => {\n // The auth otter produces { version, generatedBy, authConfig: { type: 'oidc'|'pki', ... } }\n const authConfig = artifact['authConfig'];\n if (!authConfig) return { success: true }; // skip if not present\n const result = authConfigSchema.safeParse(authConfig);\n if (!result.success) {\n const issues = result.error.issues\n .slice(0, 3)\n .map((i) => `${i.path.join('.')}: ${i.message}`)\n .join('; ');\n return { success: false, error: { message: issues } };\n }\n\n // swp-owu0 — defense-in-depth: reject RBAC theatre.\n // Fires when: >2 roles defined AND all protected routes share a single\n // requiredRole AND that role is the defaultRole. A 2-role setup\n // (e.g., ADMIN + USER, all routes require USER) is a legitimate flat\n // design and is intentionally exempted from this check.\n const rbacRoles = (authConfig as Record<string, unknown>)['rbacRoles'];\n const rbacDefaultRole = (authConfig as Record<string, unknown>)['rbacDefaultRole'];\n const protectedRoutes = (authConfig as Record<string, unknown>)['protectedRoutes'];\n if (\n Array.isArray(rbacRoles) &&\n rbacRoles.length > 2 &&\n Array.isArray(protectedRoutes) &&\n protectedRoutes.length > 0 &&\n typeof rbacDefaultRole === 'string'\n ) {\n type RouteEntry = string | { pattern: string; requiredRole?: string };\n const uniqueRoles = new Set(\n (protectedRoutes as RouteEntry[]).map((r) =>\n typeof r === 'string' ? rbacDefaultRole : (r.requiredRole ?? rbacDefaultRole)\n )\n );\n if (uniqueRoles.size === 1 && uniqueRoles.has(rbacDefaultRole)) {\n return {\n success: false,\n error: {\n message:\n `RBAC theatre detected: all ${protectedRoutes.length} protectedRoutes require ` +\n `'${rbacDefaultRole}' (the defaultRole), but ${rbacRoles.length} roles are defined. ` +\n `This defeats the RBAC hierarchy. Each route MUST have the minimum role required ` +\n `for its function. See auth-otter PER-ROUTE RBAC GRANULARITY section.`,\n },\n };\n }\n }\n\n return { success: true };\n },\n\n // swp-4071 — opt-in cross-reference validator for the services phase.\n // If workflow-config exists and declares serviceHooks, every hook ref must resolve\n // to a flow or state-machine in this services-config artifact.\n // Skips gracefully when wizard-otter didn't run (Pro-only install) or declared no hooks.\n services: (artifact) => {\n const workflowArtifactPath = join(cwd, '.stackwright', 'artifacts', 'workflow-config.json');\n if (!existsSync(workflowArtifactPath)) {\n // No workflow artifact — wizard-otter didn't run for this project. Skip.\n return { success: true };\n }\n\n let workflowArtifact: {\n serviceHooks?: Array<{ ref: string; kind?: string; purpose?: string }>;\n };\n try {\n workflowArtifact = JSON.parse(readFileSync(workflowArtifactPath, 'utf-8')) as {\n serviceHooks?: Array<{ ref: string; kind?: string; purpose?: string }>;\n };\n } catch {\n // Workflow artifact corrupt — that's the workflow phase's problem, not ours. Skip.\n return { success: true };\n }\n\n const declaredHooks = workflowArtifact.serviceHooks ?? [];\n if (declaredHooks.length === 0) {\n return { success: true }; // wizard declared no hooks\n }\n\n // Extract flow + state-machine names from this services-config artifact.\n // The services artifact has `flows` and `workflows` (state-machines) at the\n // top level — NOT nested under a 'services' key.\n const flows = (artifact['flows'] as Array<{ name: string }> | undefined) ?? [];\n const workflows = (artifact['workflows'] as Array<{ name: string }> | undefined) ?? [];\n const flowNames = new Set([...flows.map((f) => f.name), ...workflows.map((w) => w.name)]);\n\n const unresolved = declaredHooks.filter((h) => !flowNames.has(h.ref));\n if (unresolved.length > 0) {\n return {\n success: false,\n error: {\n message:\n `Workflow declared ${declaredHooks.length} service hook(s) but ` +\n `${unresolved.length} unresolved by services-config: ` +\n unresolved\n .map((h) => `${h.ref} (${h.kind ?? 'unknown'}: ${h.purpose ?? 'no purpose'})`)\n .join('; ') +\n '. Either add matching flows to services-config OR remove the hooks from the workflow YAML.',\n },\n };\n }\n\n return { success: true };\n },\n\n // qa artifact — permissive validator: requires version + generatedBy + skipped.\n // When skipped=true: skipReason must be a string.\n // When skipped=false: findings must be an array and summary must be an object.\n qa: (artifact) => {\n const skipped = artifact['skipped'];\n if (typeof skipped !== 'boolean') {\n return {\n success: false,\n error: { message: '\"skipped\" must be a boolean (true or false).' },\n };\n }\n\n if (skipped === true) {\n if (typeof artifact['skipReason'] !== 'string') {\n return {\n success: false,\n error: { message: 'When skipped=true, \"skipReason\" must be a string.' },\n };\n }\n return { success: true };\n }\n\n // skipped=false: require findings array and summary object\n if (!Array.isArray(artifact['findings'])) {\n return {\n success: false,\n error: { message: 'When skipped=false, \"findings\" must be an array.' },\n };\n }\n if (typeof artifact['summary'] !== 'object' || artifact['summary'] === null) {\n return {\n success: false,\n error: { message: 'When skipped=false, \"summary\" must be an object.' },\n };\n }\n return { success: true };\n },\n };\n\n const zodValidator = PHASE_ZOD_VALIDATORS[phase];\n if (zodValidator) {\n const zodResult = zodValidator(artifact);\n if (!zodResult.success) {\n const result: InvalidArtifactResult = {\n valid: false,\n phase,\n violation: 'schema-mismatch',\n retryPrompt: `Your artifact failed schema validation: ${zodResult.error?.message}. Fix these fields and return the corrected JSON artifact.`,\n };\n return { text: JSON.stringify(result), isError: false };\n }\n }\n\n // 6. All good — write it and sign it\n try {\n const artifactsDir = join(cwd, '.stackwright', 'artifacts');\n mkdirSync(artifactsDir, { recursive: true });\n const artifactFile = PHASE_ARTIFACT[phase];\n const artifactPath = join(artifactsDir, artifactFile);\n\n // Serialize once — sign and write the same bytes (zero TOCTOU window)\n const serialized = JSON.stringify(artifact, null, 2) + '\\n';\n const artifactBytes = Buffer.from(serialized, 'utf-8');\n safeWriteSync(artifactPath, serialized);\n\n // Sign the artifact (non-blocking — signing failure doesn't prevent write)\n let signed = false;\n try {\n const { privateKey } = loadPipelineKeys(cwd);\n const sig = signArtifact(artifactBytes, privateKey);\n const signerOtter = PHASE_TO_OTTER[phase];\n saveArtifactSignature(cwd, artifactFile, sig, signerOtter);\n signed = true;\n } catch {\n // Signing is defense-in-depth — pipeline continues without signatures\n // if keys haven't been initialized (e.g., backwards-compat with old pipelines)\n }\n\n // Update pipeline state\n const state = readState(cwd);\n if (!state.phases[phase]) state.phases[phase] = defaultPhaseStatus();\n const ps = state.phases[phase]!; // guaranteed by line above\n ps.artifactWritten = true;\n writeState(cwd, state);\n\n const topKeys = Object.keys(artifact).slice(0, 5).join(', ');\n const result: ValidArtifactResult = {\n valid: true,\n phase,\n artifactPath,\n summary: `Wrote ${artifactFile} (keys: ${topKeys}${Object.keys(artifact).length > 5 ? ', ...' : ''})${signed ? ' [signed]' : ''}`,\n };\n return { text: JSON.stringify(result), isError: false };\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n return { text: JSON.stringify({ error: true, phase, message }), isError: true };\n }\n}\n\n/**\n * Public API — wraps _validateArtifactInner with best-effort telemetry (swp-im3c).\n * All emit() calls are fire-and-forget; failures never surface to callers.\n */\nexport function handleValidateArtifact(input: ValidateArtifactInput): {\n text: string;\n isError: boolean;\n} {\n const cwd = input._cwd ?? process.cwd();\n const { phase } = input;\n // Telemetry: best-effort, never blocks (swp-im3c)\n emit(\n { type: 'tool_call', toolName: 'stackwright_pro_validate_artifact', args: { phase }, phase },\n { cwd }\n );\n const result = _validateArtifactInner(input);\n try {\n const parsed = JSON.parse(result.text) as { valid?: boolean; artifactPath?: string };\n if (parsed.valid === true && parsed.artifactPath) {\n // Telemetry: best-effort, never blocks (swp-im3c)\n emit({ type: 'file_write', path: parsed.artifactPath, phase }, { cwd });\n }\n // Telemetry: best-effort, never blocks (swp-im3c)\n emit(\n {\n type: 'tool_complete',\n toolName: 'stackwright_pro_validate_artifact',\n success: parsed.valid === true,\n phase,\n },\n { cwd }\n );\n } catch {\n // Telemetry parse/emit errors never surface to callers\n }\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// MCP tool registration\n// ---------------------------------------------------------------------------\n\nexport function registerPipelineTools(server: McpServer): void {\n const DESC = 'Writes state to .stackwright/ — the filesystem is the state machine.';\n const res = (r: { text: string; isError: boolean }) => ({\n content: [{ type: 'text' as const, text: r.text }],\n isError: r.isError,\n });\n\n server.tool(\n 'stackwright_pro_get_pipeline_state',\n `Read pipeline state from .stackwright/pipeline-state.json. ${DESC}`,\n {},\n async () => res(handleGetPipelineState())\n );\n\n server.tool(\n 'stackwright_pro_set_pipeline_state',\n `Atomic read→modify→write pipeline state. ${DESC}`,\n {\n phase: z.string().optional().describe('Phase to update, e.g. \"designer\"'),\n field: z\n .enum(['questionsCollected', 'answered', 'executed', 'artifactWritten'])\n .optional()\n .describe('Boolean field to set'),\n value: boolCoerce(z.boolean().optional()).describe(\n 'Value for the field — must be a JSON boolean (true/false), NOT the string \"true\"/\"false\"'\n ),\n status: z\n .enum(['setup', 'questions', 'execution', 'done'])\n .optional()\n .describe('Top-level status override'),\n incrementRetry: boolCoerce(z.boolean().optional()).describe(\n 'Bump retryCount by 1 — must be a JSON boolean'\n ),\n updates: jsonCoerce(\n z\n .array(\n z.object({\n phase: z.string(),\n field: z.enum(['questionsCollected', 'answered', 'executed', 'artifactWritten']),\n value: z.boolean(),\n })\n )\n .optional()\n ).describe('Batch updates — apply multiple field changes in one atomic read→modify→write'),\n },\n async (args) =>\n res(\n handleSetPipelineState({\n ...(args.phase != null ? { phase: args.phase } : {}),\n ...(args.field != null ? { field: args.field } : {}),\n ...(args.value != null ? { value: args.value } : {}),\n ...(args.status != null ? { status: args.status } : {}),\n ...(args.incrementRetry != null ? { incrementRetry: args.incrementRetry } : {}),\n ...(args.updates != null ? { updates: args.updates as BatchStateUpdate[] } : {}),\n })\n )\n );\n\n server.tool(\n 'stackwright_pro_check_execution_ready',\n `Check all phases have answer files in .stackwright/answers/. If phase is provided, check only that phase. ${DESC}`,\n {\n phase: z\n .string()\n .optional()\n .describe(\"If provided, check only this phase's readiness. Omit to check all phases.\"),\n },\n async ({ phase }) => res(handleCheckExecutionReady(undefined, phase))\n );\n\n server.tool(\n 'stackwright_pro_get_ready_phases',\n `Return phases whose dependencies are all satisfied (executed=true). Use to determine which phases can run next — enables wave-based execution. ${DESC}`,\n {},\n async () => res(handleGetReadyPhases())\n );\n\n server.tool(\n 'stackwright_pro_list_artifacts',\n `List phase artifacts in .stackwright/artifacts/ with completedCount/totalCount. ${DESC}`,\n {},\n async () => res(handleListArtifacts())\n );\n\n server.tool(\n 'stackwright_pro_write_phase_questions',\n `Parse otter question-collection response → .stackwright/questions/{phase}.json. Specialists may also call this directly with a parsed questions array. ${DESC}`,\n {\n phase: z\n .string()\n .optional()\n .describe('Phase name, e.g. \"designer\" (required for direct write)'),\n responseText: z\n .string()\n .optional()\n .describe('Raw LLM response from QUESTION_COLLECTION_MODE'),\n questions: jsonCoerce(z.array(z.any()).optional()).describe(\n 'Questions array for direct specialist write'\n ),\n },\n async ({ phase, responseText, questions }) => {\n // Direct write path: specialist calls with parsed array (no responseText parsing)\n if (phase && questions && Array.isArray(questions)) {\n if (!SAFE_PHASE.test(phase)) {\n return {\n content: [\n { type: 'text' as const, text: JSON.stringify({ error: `Invalid phase name` }) },\n ],\n isError: true,\n };\n }\n const questionsDir = join(process.cwd(), '.stackwright', 'questions');\n mkdirSync(questionsDir, { recursive: true });\n const outPath = join(questionsDir, `${phase}.json`);\n writeFileSync(\n outPath,\n JSON.stringify({ phase, questions, writtenAt: new Date().toISOString() }, null, 2)\n );\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({\n written: true,\n phase,\n count: (questions as unknown[]).length,\n }),\n },\n ],\n };\n }\n // Fallback: parse from raw LLM responseText\n return res(\n handleWritePhaseQuestions({ phase: phase ?? '', responseText: responseText ?? '' })\n );\n }\n );\n\n server.tool(\n 'stackwright_pro_build_specialist_prompt',\n `Assemble execution prompt from answers + upstream artifacts. Foreman passes verbatim. ${DESC}`,\n { phase: z.string().describe('Phase to build prompt for, e.g. \"pages\"') },\n async ({ phase }) => res(handleBuildSpecialistPrompt({ phase }))\n );\n\n server.tool(\n 'stackwright_pro_validate_artifact',\n `Validate and write artifact to .stackwright/artifacts/. Returns retryPrompt on failure. ${DESC}`,\n {\n phase: z.string().describe('Phase that produced this artifact, e.g. \"designer\"'),\n responseText: z\n .string()\n .optional()\n .describe('Raw response text from the specialist otter (Foreman-mediated path, legacy)'),\n artifact: jsonCoerce(z.record(z.string(), z.unknown()).optional()).describe(\n 'Artifact object to validate and write directly (specialist direct path — skips off-script detection and JSON parsing)'\n ),\n },\n async ({ phase, responseText, artifact }) => {\n if (artifact) {\n return res(\n handleValidateArtifact({ phase, artifact: artifact as Record<string, unknown> })\n );\n }\n return res(handleValidateArtifact({ phase, responseText: responseText ?? '' }));\n }\n );\n\n // ---------------------------------------------------------------------------\n // Foreman lifecycle emission tool (swp-im3c)\n // Foreman calls this at phase boundaries and agent-invoke boundaries.\n // Specialist otters never call this — telemetry comes from handler wraps above.\n // ---------------------------------------------------------------------------\n server.tool(\n 'stackwright_pro_emit_event',\n `Emit a telemetry event to .stackwright/pipeline-events.ndjson. ` +\n `Foreman calls this at phase boundaries and agent-invoke boundaries. ` +\n `Best-effort: failures are silent and never block. ${DESC}`,\n {\n type: z\n .enum(['phase_start', 'phase_complete', 'agent_invoke_start', 'agent_invoke_complete'])\n .describe('Event type to emit'),\n phase: z.string().optional().describe('Current phase name'),\n targetOtter: z\n .string()\n .optional()\n .describe('For agent_invoke_* events: which otter is being invoked'),\n success: z\n .boolean()\n .optional()\n .describe('For agent_invoke_complete: did the invocation succeed?'),\n otter: z.string().optional().describe('Which otter is emitting (defaults to \"foreman\")'),\n durationSec: z.number().optional().describe('Duration in seconds (for *_complete events)'),\n },\n async ({ type, phase, targetOtter, success, otter, durationSec }) => {\n let emitted = false;\n try {\n if (type === 'phase_start') {\n if (!phase) {\n return res({\n text: JSON.stringify({ emitted: false, reason: 'phase required for phase_start' }),\n isError: false,\n });\n }\n emitted = emit({ type: 'phase_start', phase, otter: otter ?? 'foreman' });\n } else if (type === 'phase_complete') {\n if (!phase) {\n return res({\n text: JSON.stringify({ emitted: false, reason: 'phase required for phase_complete' }),\n isError: false,\n });\n }\n emitted = emit({\n type: 'phase_complete',\n phase,\n otter: otter ?? 'foreman',\n ...(durationSec != null ? { durationSec } : {}),\n });\n } else if (type === 'agent_invoke_start') {\n emitted = emit({\n type: 'agent_invoke_start',\n targetOtter: targetOtter ?? '',\n phase,\n otter: otter ?? 'foreman',\n });\n } else if (type === 'agent_invoke_complete') {\n emitted = emit({\n type: 'agent_invoke_complete',\n targetOtter: targetOtter ?? '',\n success: success ?? true,\n phase,\n otter: otter ?? 'foreman',\n ...(durationSec != null ? { durationSec } : {}),\n });\n }\n } catch {\n // Telemetry errors never surface to callers\n }\n return res({ text: JSON.stringify({ emitted }), isError: false });\n }\n );\n}\n","/**\n * LLM Input Coercion Utilities — \"Sinks Not Pipes\"\n *\n * LLMs frequently serialize complex values to JSON strings before passing them\n * as MCP tool arguments. These utilities wrap Zod schemas with z.preprocess\n * steps that coerce the common mis-serializations back to native types before\n * Zod validates them.\n *\n * Usage:\n * import { jsonCoerce, boolCoerce, numCoerce } from '../coerce.js';\n * // In a server.tool() schema:\n * myArray: jsonCoerce(z.array(z.string())).describe('...'),\n * myBool: boolCoerce(z.boolean().optional()).describe('...'),\n * myNum: numCoerce(z.number().optional()).describe('...'),\n *\n * Design note: z.preprocess uses the INNER schema for JSON Schema generation,\n * so the MCP tool's inputSchema still shows the correct native type to the LLM.\n * The coercion is invisible to the model — it only fires when the model sends\n * the wrong type.\n */\nimport { z } from 'zod';\n\n/**\n * JSON-coerce: If the input is a string, attempt JSON.parse before validation.\n * Handles LLM string-serialization of arrays (`\"[...]\"`), objects (`\"{...}\"`),\n * and records. If JSON.parse fails, the original string is passed through and\n * Zod produces a clear type error.\n */\nexport function jsonCoerce<T extends z.ZodTypeAny>(schema: T) {\n return z.preprocess((v) => {\n if (typeof v === 'string') {\n try {\n return JSON.parse(v);\n } catch {\n return v; // Zod will reject it with a clear error\n }\n }\n return v;\n }, schema);\n}\n\n/**\n * Bool-coerce: Converts the strings \"true\" and \"false\" to their boolean\n * equivalents. Uses explicit string matching — NOT JavaScript truthiness —\n * so the string \"false\" correctly maps to boolean false.\n */\nexport function boolCoerce<T extends z.ZodTypeAny>(schema: T) {\n return z.preprocess((v) => (v === 'true' ? true : v === 'false' ? false : v), schema);\n}\n\n/**\n * Num-coerce: Converts pure numeric strings (\"42\", \"3.14\") to numbers.\n * Non-numeric strings pass through unchanged (Zod produces a clear error).\n */\nexport function numCoerce<T extends z.ZodTypeAny>(schema: T) {\n return z.preprocess((v) => {\n if (typeof v === 'string' && v.trim() !== '') {\n const n = Number(v);\n if (!Number.isNaN(n)) return n;\n }\n return v;\n }, schema);\n}\n","/**\n * Artifact Signing — Cryptographic Pipeline Artifact Protection\n * ==============================================================\n * Closes the mid-pipeline TOCTOU gap where artifacts written by one otter\n * can be tampered with before the next otter reads them.\n *\n * integrity.ts handles SHA-256 pinning of otter *definition* files at startup.\n * This module protects pipeline *artifacts* (`.stackwright/artifacts/`) with\n * ECDSA P-384 signatures. Ephemeral key pairs are per-pipeline-run, destroyed\n * after completion.\n */\nimport {\n createHash,\n generateKeyPairSync,\n createPublicKey,\n createPrivateKey,\n sign,\n verify,\n timingSafeEqual,\n KeyObject,\n} from 'crypto';\nimport {\n readFileSync,\n writeFileSync,\n existsSync,\n mkdirSync,\n lstatSync,\n unlinkSync,\n readdirSync,\n} from 'fs';\nimport { join } from 'path';\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst ALGORITHM = 'ECDSA-P384-SHA384' as const;\nconst KEY_FILE = 'pipeline-keys.json';\nconst KEY_DIR = '.stackwright';\nconst SIGNATURE_MANIFEST = 'signatures.json';\nconst ARTIFACTS_DIR = '.stackwright/artifacts';\n\n// ---------------------------------------------------------------------------\n// Exported types & interfaces\n// ---------------------------------------------------------------------------\n\n/** Signature record for a single artifact. */\nexport interface ArtifactSignature {\n digest: string;\n signature: string;\n algorithm: typeof ALGORITHM;\n signedAt: string;\n}\n\n/** Manifest entry — extends ArtifactSignature with signer identity. */\nexport interface ManifestEntry extends ArtifactSignature {\n signedBy: string;\n}\n\n/** On-disk shape of `.stackwright/artifacts/signatures.json`. */\nexport interface SignatureManifest {\n version: '1.0';\n algorithm: typeof ALGORITHM;\n signatures: Record<string, ManifestEntry>;\n}\n\n/** On-disk shape of `.stackwright/pipeline-keys.json`. */\nexport interface PipelineKeyFile {\n version: '1.0';\n algorithm: typeof ALGORITHM;\n createdAt: string;\n publicKeyPem: string;\n privateKeyPem: string;\n}\n\n/** Structured audit event for a signature failure. */\nexport interface SignatureAuditEvent {\n artifactFilename: string;\n expectedDigest: string;\n actualDigest: string;\n phase: string;\n timestamp: string;\n source: string;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/** Reject symlinks at a given path. No-op if path doesn't exist. */\nfunction rejectSymlink(filePath: string, context: string): void {\n if (!existsSync(filePath)) return;\n const stat = lstatSync(filePath);\n if (stat.isSymbolicLink()) {\n throw new Error(`Security: refusing to follow symlink at ${context}: ${filePath}`);\n }\n}\n\n/** Hex-encoded SHA-384 digest of raw bytes. Pure, no I/O. */\nfunction computeSha384(data: Buffer): string {\n return createHash('sha384').update(data).digest('hex');\n}\n\n/** Constant-time comparison of two hex digest strings. */\nfunction safeDigestEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));\n}\n\n/** Build an empty manifest with the correct shape. */\nfunction emptyManifest(): SignatureManifest {\n return {\n version: '1.0',\n algorithm: ALGORITHM,\n signatures: {},\n };\n}\n\n// ---------------------------------------------------------------------------\n// 1. Ephemeral Key Pair Management (ECDSA P-384)\n// ---------------------------------------------------------------------------\n\n/** Generate an ECDSA P-384 key pair, write to `.stackwright/pipeline-keys.json`. */\nexport function initPipelineKeys(cwd: string): { publicKeyPem: string; fingerprint: string } {\n const keyDir = join(cwd, KEY_DIR);\n const keyPath = join(keyDir, KEY_FILE);\n\n // Reject symlinks on the key file path\n rejectSymlink(keyPath, 'pipeline-keys.json');\n\n // Ensure directory exists\n mkdirSync(keyDir, { recursive: true });\n\n // Generate ECDSA P-384 key pair\n const { publicKey, privateKey } = generateKeyPairSync('ec', {\n namedCurve: 'P-384',\n });\n\n const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;\n const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;\n\n // SHA-256 fingerprint of the public key PEM\n const fingerprint = createHash('sha256').update(publicKeyPem).digest('hex');\n\n const keyFile: PipelineKeyFile = {\n version: '1.0',\n algorithm: ALGORITHM,\n createdAt: new Date().toISOString(),\n publicKeyPem,\n privateKeyPem,\n };\n\n writeFileSync(keyPath, JSON.stringify(keyFile, null, 2), { encoding: 'utf-8' });\n\n return { publicKeyPem, fingerprint };\n}\n\n/** Load pipeline key pair from disk. Validates PEM, rejects symlinks. */\nexport function loadPipelineKeys(cwd: string): { privateKey: KeyObject; publicKey: KeyObject } {\n const keyPath = join(cwd, KEY_DIR, KEY_FILE);\n\n // Reject symlinks\n rejectSymlink(keyPath, 'pipeline-keys.json');\n\n if (!existsSync(keyPath)) {\n throw new Error('Pipeline keys not found — call initPipelineKeys() first');\n }\n\n let raw: string;\n try {\n raw = readFileSync(keyPath, 'utf-8');\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`Cannot read pipeline keys: ${msg}`, { cause: err });\n }\n\n let parsed: PipelineKeyFile;\n try {\n parsed = JSON.parse(raw) as PipelineKeyFile;\n } catch {\n throw new Error('Pipeline keys file is not valid JSON');\n }\n\n // Basic PEM format validation\n if (\n typeof parsed.publicKeyPem !== 'string' ||\n !parsed.publicKeyPem.includes('-----BEGIN PUBLIC KEY-----')\n ) {\n throw new Error('Invalid public key PEM in pipeline keys file');\n }\n if (typeof parsed.privateKeyPem !== 'string' || !parsed.privateKeyPem.includes('-----BEGIN')) {\n throw new Error('Invalid private key PEM in pipeline keys file');\n }\n\n const publicKey = createPublicKey(parsed.publicKeyPem);\n const privateKey = createPrivateKey(parsed.privateKeyPem);\n\n return { privateKey, publicKey };\n}\n\n/** Overwrite private key material with zeros then delete. Idempotent. */\nexport function destroyPipelineKeys(cwd: string): void {\n const keyPath = join(cwd, KEY_DIR, KEY_FILE);\n\n if (!existsSync(keyPath)) return;\n\n // Reject symlinks even during destruction\n rejectSymlink(keyPath, 'pipeline-keys.json (destroy)');\n\n // Read → overwrite private key material with zeros → delete\n try {\n const raw = readFileSync(keyPath, 'utf-8');\n const parsed = JSON.parse(raw) as PipelineKeyFile;\n parsed.privateKeyPem = '0'.repeat(parsed.privateKeyPem.length);\n parsed.publicKeyPem = '0'.repeat(parsed.publicKeyPem.length);\n writeFileSync(keyPath, JSON.stringify(parsed, null, 2), { encoding: 'utf-8' });\n } catch {\n // Best-effort overwrite — proceed to delete regardless\n }\n\n try {\n unlinkSync(keyPath);\n } catch {\n // Already gone — idempotent\n }\n}\n\n// ---------------------------------------------------------------------------\n// 2. Artifact Signing\n// ---------------------------------------------------------------------------\n\n/** Sign artifact bytes with ECDSA P-384. Returns structured signature record. */\nexport function signArtifact(artifactBytes: Buffer, privateKey: KeyObject): ArtifactSignature {\n const digest = computeSha384(artifactBytes);\n const sig = sign('SHA384', artifactBytes, privateKey);\n\n return {\n digest,\n signature: sig.toString('base64'),\n algorithm: ALGORITHM,\n signedAt: new Date().toISOString(),\n };\n}\n\n// ---------------------------------------------------------------------------\n// 3. Artifact Verification\n// ---------------------------------------------------------------------------\n\n/** Verify ECDSA signature AND constant-time SHA-384 digest. Both must pass. */\nexport function verifyArtifact(\n artifactBytes: Buffer,\n signature: ArtifactSignature,\n publicKey: KeyObject\n): boolean {\n // Layer 1: ECDSA signature verification\n const sigValid = verify(\n 'SHA384',\n artifactBytes,\n publicKey,\n Buffer.from(signature.signature, 'base64')\n );\n\n if (!sigValid) return false;\n\n // Layer 2: Constant-time digest comparison\n const actualDigest = computeSha384(artifactBytes);\n return safeDigestEqual(actualDigest, signature.digest);\n}\n\n// ---------------------------------------------------------------------------\n// 4. Signature Manifest Management\n// ---------------------------------------------------------------------------\n\n/** Load signature manifest. Returns empty manifest if file doesn't exist. */\nexport function loadSignatureManifest(cwd: string): SignatureManifest {\n const manifestPath = join(cwd, ARTIFACTS_DIR, SIGNATURE_MANIFEST);\n\n rejectSymlink(manifestPath, 'signatures.json');\n\n if (!existsSync(manifestPath)) return emptyManifest();\n\n let raw: string;\n try {\n raw = readFileSync(manifestPath, 'utf-8');\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`Cannot read signature manifest: ${msg}`, { cause: err });\n }\n\n try {\n const parsed = JSON.parse(raw) as SignatureManifest;\n // Basic shape validation — don't trust what's on disk blindly\n if (parsed.version !== '1.0' || typeof parsed.signatures !== 'object') {\n throw new Error('Malformed signature manifest: invalid version or missing signatures object');\n }\n return parsed;\n } catch (err) {\n if (err instanceof Error && err.message.startsWith('Malformed')) throw err;\n throw new Error('Signature manifest is not valid JSON', { cause: err });\n }\n}\n\n/** Save a signature entry for a specific artifact. Read-modify-write. */\nexport function saveArtifactSignature(\n cwd: string,\n artifactFilename: string,\n sig: ArtifactSignature,\n signerOtter: string\n): void {\n const artifactsDir = join(cwd, ARTIFACTS_DIR);\n const manifestPath = join(artifactsDir, SIGNATURE_MANIFEST);\n\n rejectSymlink(manifestPath, 'signatures.json (save)');\n\n // Ensure artifacts directory exists\n mkdirSync(artifactsDir, { recursive: true });\n\n const manifest = loadSignatureManifest(cwd);\n\n manifest.signatures[artifactFilename] = {\n ...sig,\n signedBy: signerOtter,\n };\n\n writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), { encoding: 'utf-8' });\n}\n\n/** Retrieve signature for a specific artifact, or null if absent. */\nexport function getArtifactSignature(\n cwd: string,\n artifactFilename: string\n): ArtifactSignature | null {\n const manifest = loadSignatureManifest(cwd);\n const entry = manifest.signatures[artifactFilename];\n if (!entry) return null;\n\n // Return just the ArtifactSignature fields (strip signedBy)\n return {\n digest: entry.digest,\n signature: entry.signature,\n algorithm: entry.algorithm,\n signedAt: entry.signedAt,\n };\n}\n\n// ---------------------------------------------------------------------------\n// 5. Audit Events — SOC2 / FedRAMP / DoD ATO compliance\n// ---------------------------------------------------------------------------\n\n/**\n * Emit a structured ARTIFACT_SIGNATURE_FAIL audit event to stderr.\n *\n * Line prefix allows log shippers (FluentBit, syslog, CloudWatch, Splunk)\n * to route events via simple string filter before JSON parsing.\n */\nexport function emitSignatureAuditEvent(params: SignatureAuditEvent): void {\n const record = JSON.stringify({\n level: 'AUDIT',\n event: 'ARTIFACT_SIGNATURE_FAIL',\n timestamp: params.timestamp,\n source: params.source,\n artifactFilename: params.artifactFilename,\n expectedDigest: params.expectedDigest,\n actualDigest: params.actualDigest,\n phase: params.phase,\n });\n process.stderr.write(`ARTIFACT_SIGNATURE_FAIL ${record}\\n`);\n}\n\n// ---------------------------------------------------------------------------\n// 6. MCP Tool Registration\n// ---------------------------------------------------------------------------\n\n/** Per-artifact verification result returned by the MCP tool. */\nexport interface ArtifactVerificationResult {\n filename: string;\n verified: boolean;\n error?: string;\n signedBy?: string;\n signedAt?: string;\n}\n\n/** Register artifact signing verification tool on the MCP server. */\nexport function registerArtifactSigningTools(server: McpServer): void {\n server.tool(\n 'stackwright_pro_verify_artifact_signatures',\n 'Verify ECDSA P-384 signatures for all pipeline artifacts in .stackwright/artifacts/. ' +\n 'Auto-discovers keys from .stackwright/pipeline-keys.json. ' +\n 'Returns per-artifact verification status.',\n {\n cwd: z.string().optional().describe('Project root directory. Defaults to process.cwd().'),\n },\n async ({ cwd: cwdParam }) => {\n const cwd = cwdParam ?? process.cwd();\n\n // 1. Load pipeline keys\n let publicKey: KeyObject;\n try {\n const keys = loadPipelineKeys(cwd);\n publicKey = keys.publicKey;\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({\n error: true,\n message: `Cannot load pipeline keys: ${msg}`,\n }),\n },\n ],\n isError: true,\n };\n }\n\n // 2. Load signature manifest\n let manifest: SignatureManifest;\n try {\n manifest = loadSignatureManifest(cwd);\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({\n error: true,\n message: `Cannot load signature manifest: ${msg}`,\n }),\n },\n ],\n isError: true,\n };\n }\n\n // 3. Discover artifact files on disk\n const artifactsPath = join(cwd, ARTIFACTS_DIR);\n let artifactFiles: string[] = [];\n try {\n if (existsSync(artifactsPath)) {\n artifactFiles = readdirSync(artifactsPath).filter(\n (f) => f.endsWith('.json') && f !== SIGNATURE_MANIFEST\n );\n }\n } catch {\n // If we can't read the directory, we'll report zero artifacts\n }\n\n // 4. Verify each artifact\n const results: ArtifactVerificationResult[] = [];\n let hasFailure = false;\n\n for (const filename of artifactFiles) {\n const filePath = join(artifactsPath, filename);\n\n // Symlink guard\n try {\n rejectSymlink(filePath, `artifact ${filename}`);\n } catch {\n results.push({\n filename,\n verified: false,\n error: 'Refusing to verify symlink',\n });\n hasFailure = true;\n continue;\n }\n\n // Read artifact bytes\n let artifactBytes: Buffer;\n try {\n artifactBytes = readFileSync(filePath);\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n results.push({\n filename,\n verified: false,\n error: `Cannot read artifact: ${msg}`,\n });\n hasFailure = true;\n continue;\n }\n\n // Look up signature in manifest\n const entry = manifest.signatures[filename];\n if (!entry) {\n results.push({\n filename,\n verified: false,\n error: 'No signature found in manifest',\n });\n hasFailure = true;\n continue;\n }\n\n // Verify\n const sig: ArtifactSignature = {\n digest: entry.digest,\n signature: entry.signature,\n algorithm: entry.algorithm,\n signedAt: entry.signedAt,\n };\n\n const verified = verifyArtifact(artifactBytes, sig, publicKey);\n\n if (!verified) {\n const actualDigest = computeSha384(artifactBytes);\n emitSignatureAuditEvent({\n artifactFilename: filename,\n expectedDigest: sig.digest,\n actualDigest,\n phase: entry.signedBy ?? 'unknown',\n timestamp: new Date().toISOString(),\n source: 'stackwright_pro_verify_artifact_signatures',\n });\n\n results.push({\n filename,\n verified: false,\n error: `Signature verification failed — artifact may have been tampered with`,\n signedBy: entry.signedBy,\n signedAt: entry.signedAt,\n });\n hasFailure = true;\n } else {\n results.push({\n filename,\n verified: true,\n signedBy: entry.signedBy,\n signedAt: entry.signedAt,\n });\n }\n }\n\n // 5. Report artifacts in manifest that are missing from disk\n for (const manifestFilename of Object.keys(manifest.signatures)) {\n if (!artifactFiles.includes(manifestFilename)) {\n results.push({\n filename: manifestFilename,\n verified: false,\n error: 'Artifact referenced in manifest but missing from disk',\n });\n hasFailure = true;\n }\n }\n\n const verifiedCount = results.filter((r) => r.verified).length;\n const failedCount = results.filter((r) => !r.verified).length;\n\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({\n totalArtifacts: artifactFiles.length,\n verifiedCount,\n failedCount,\n results,\n ...(hasFailure\n ? {\n error:\n 'SIGNATURE VERIFICATION FAILED: One or more artifact signatures are invalid. ' +\n 'Do not proceed — artifacts may have been tampered with.',\n }\n : {}),\n }),\n },\n ],\n isError: hasFailure,\n };\n }\n );\n}\n","/**\n * Get Schema Tool — \"Sinks Not Pipes\"\n *\n * Returns a compact, LLM-friendly representation of a canonical Zod schema.\n * Derived programmatically via z.toJSONSchema() — never hardcoded — so schema\n * and documentation can never drift from each other.\n *\n * Used by the foreman during build_specialist_prompt to inject canonical schema\n * references into specialist prompts. This is the source-of-truth injection layer\n * for swp-bf6: specialists receive the actual schema, not a prose copy.\n */\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\nimport {\n WorkflowStepSchema,\n WorkflowDefinitionSchema,\n WorkflowFieldSchema,\n WorkflowActionSchema,\n authConfigSchema,\n} from '@stackwright-pro/types';\nimport {\n SUPPORTED_SCHEMA_NAMES,\n FIELD_SYNONYMS,\n type SchemaName,\n} from './validate-yaml-fragment.js';\n\n// Re-export so callers can import from one place\nexport { SUPPORTED_SCHEMA_NAMES };\n\n// ---------------------------------------------------------------------------\n// Inline navigation schema (mirrors validate-yaml-fragment.ts — DRY violation\n// risk is acceptable here: both files are small and colocated. The alternative\n// (a shared-types file for just two schemas) would be over-engineering.)\n// ---------------------------------------------------------------------------\n\nconst NavigationLinkSchema: z.ZodType<unknown> = z.lazy(() =>\n z.object({\n label: z.string().min(1),\n href: z.string().min(1),\n children: z.array(NavigationLinkSchema).optional(),\n })\n);\n\nconst NavigationSectionSchema = z.object({\n section: z.string().min(1),\n items: z.array(NavigationLinkSchema),\n});\n\nconst NavigationItemSchema = z.union([\n NavigationLinkSchema as z.ZodObject<any>,\n NavigationSectionSchema,\n]);\n\n// ---------------------------------------------------------------------------\n// Schema registry (same set as validate-yaml-fragment)\n// ---------------------------------------------------------------------------\n\nfunction getZodSchema(name: SchemaName): z.ZodTypeAny {\n switch (name) {\n case 'workflow_step':\n return WorkflowStepSchema;\n case 'workflow_definition':\n return WorkflowDefinitionSchema;\n case 'workflow_field':\n return WorkflowFieldSchema;\n case 'workflow_action':\n return WorkflowActionSchema;\n case 'navigation_item':\n return NavigationItemSchema;\n case 'auth_config':\n return authConfigSchema;\n }\n}\n\n// ---------------------------------------------------------------------------\n// JSON Schema → compact text format\n// ---------------------------------------------------------------------------\n\ntype JsonSchemaNode = {\n type?: string | string[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n enum?: unknown[];\n const?: unknown;\n oneOf?: JsonSchemaNode[];\n anyOf?: JsonSchemaNode[];\n items?: JsonSchemaNode;\n maxLength?: number;\n minLength?: number;\n pattern?: string;\n minimum?: number;\n maximum?: number;\n additionalProperties?: boolean;\n description?: string;\n};\n\nfunction formatType(node: JsonSchemaNode, depth = 0): string {\n if (node.const !== undefined) return JSON.stringify(node.const);\n if (node.enum) return node.enum.map((v) => JSON.stringify(v)).join(' | ');\n\n if (node.oneOf || node.anyOf) {\n const branches = node.oneOf ?? node.anyOf ?? [];\n // Discriminated union — show all variant types\n return branches\n .map((b) => {\n const disc = b.properties\n ? Object.entries(b.properties)\n .filter(([, v]) => v.const !== undefined)\n .map(([k, v]) => `${k}: ${JSON.stringify(v.const)}`)\n .join(', ')\n : '';\n return disc ? `{ ${disc}, ... }` : '{ ... }';\n })\n .join(' | ');\n }\n\n if (node.type === 'array' && node.items) {\n return `${formatType(node.items, depth)}[]`;\n }\n\n if (node.type === 'object' && node.properties && depth < 2) {\n const inner = Object.entries(node.properties)\n .map(([k, v]) => `${k}: ${formatType(v, depth + 1)}`)\n .join(', ');\n return `{ ${inner} }`;\n }\n\n const base = Array.isArray(node.type) ? node.type.join(' | ') : (node.type ?? 'unknown');\n\n const constraints: string[] = [];\n if (node.maxLength !== undefined) constraints.push(`max ${node.maxLength} chars`);\n if (node.minLength !== undefined) constraints.push(`min ${node.minLength} chars`);\n if (node.pattern) constraints.push(`pattern: ${node.pattern}`);\n if (node.minimum !== undefined) constraints.push(`min ${node.minimum}`);\n if (node.maximum !== undefined) constraints.push(`max ${node.maximum}`);\n\n return constraints.length > 0 ? `${base} (${constraints.join(', ')})` : base;\n}\n\nfunction formatObjectSchema(\n node: JsonSchemaNode,\n schemaName: string,\n synonyms: Record<string, string>\n): string {\n const lines: string[] = [];\n const displayName = schemaName\n .split('_')\n .map((w) => w[0].toUpperCase() + w.slice(1))\n .join('');\n\n lines.push(`${displayName}:`);\n\n // Handle discriminated unions (oneOf/anyOf at the top level)\n if (node.oneOf || node.anyOf) {\n const branches = node.oneOf ?? node.anyOf ?? [];\n lines.push(' Discriminated union — choose one variant:');\n for (const branch of branches) {\n const req = new Set(branch.required ?? []);\n const disc = Object.entries(branch.properties ?? {})\n .filter(([, v]) => v.const !== undefined)\n .map(([k, v]) => `${k}: ${JSON.stringify(v.const)}`)\n .join(', ');\n lines.push(` VARIANT (${disc}):`);\n for (const [field, fieldNode] of Object.entries(branch.properties ?? {})) {\n if (fieldNode.const !== undefined) continue; // discriminant shown in header\n const isRequired = req.has(field);\n const hint = synonyms[field] ? ` -- WARNING: use ${field}: not ${synonyms[field]}` : '';\n lines.push(\n ` ${isRequired ? 'REQUIRED' : 'optional'}: ${field}: ${formatType(fieldNode)}${hint}`\n );\n }\n }\n return lines.join('\\n');\n }\n\n // Object schema\n const required = new Set(node.required ?? []);\n const properties = node.properties ?? {};\n\n const requiredFields = Object.entries(properties).filter(([k]) => required.has(k));\n const optionalFields = Object.entries(properties).filter(([k]) => !required.has(k));\n\n if (requiredFields.length > 0) {\n lines.push(' REQUIRED:');\n for (const [field, fieldNode] of requiredFields) {\n const typeStr = formatType(fieldNode);\n const warn = synonyms[field] ? ` -- CAUTION: this schema uses ${field}` : '';\n // Attach warnings for the *correct* field that is also a synonym target\n // Actually: warn for wrong names that newcomers use instead of this field\n lines.push(` ${field}: ${typeStr}${warn}`);\n }\n }\n\n if (optionalFields.length > 0) {\n lines.push(' OPTIONAL:');\n for (const [field, fieldNode] of optionalFields) {\n const typeStr = formatType(fieldNode);\n lines.push(` ${field}: ${typeStr}`);\n }\n }\n\n return lines.join('\\n');\n}\n\n// ---------------------------------------------------------------------------\n// Exported handler\n// ---------------------------------------------------------------------------\n\nexport interface GetSchemaInput {\n schemaName: string;\n}\n\nexport interface GetSchemaResult {\n schemaName: string;\n summary: string;\n synonymWarnings: string[];\n tokenEstimate: number;\n}\n\nexport function handleGetSchema(input: GetSchemaInput): GetSchemaResult | { error: string } {\n const { schemaName } = input;\n\n if (!(SUPPORTED_SCHEMA_NAMES as readonly string[]).includes(schemaName)) {\n return {\n error: `Unknown schemaName: \"${schemaName}\". Supported: ${SUPPORTED_SCHEMA_NAMES.join(', ')}`,\n };\n }\n\n const name = schemaName as SchemaName;\n const schema = getZodSchema(name);\n const synonyms = FIELD_SYNONYMS[name];\n\n // Derive schema summary from Zod via JSON Schema conversion\n let jsonSchema: JsonSchemaNode;\n try {\n // toJSONSchema strips $schema wrapper — we work with the raw shape\n const raw = z.toJSONSchema(schema, { unrepresentable: 'any' }) as JsonSchemaNode;\n // Remove $schema field (not useful for LLM consumption)\n jsonSchema = raw;\n } catch {\n // Fallback for lazy schemas (NavigationItem uses z.lazy)\n jsonSchema = { type: 'object', description: 'Schema introspection unavailable for this type' };\n }\n\n const summary = formatObjectSchema(jsonSchema, name, synonyms);\n\n // Append synonym warnings as a dedicated section\n const synonymWarnings = Object.entries(synonyms).map(\n ([wrong, correct]) => ` WARNING: use \"${correct}\" — NOT \"${wrong}\"`\n );\n\n // Rough token estimate: ~4 chars per token\n const fullText = [\n summary,\n '',\n 'FIELD NAME WARNINGS (common drift patterns):',\n ...synonymWarnings,\n ].join('\\n');\n const tokenEstimate = Math.ceil(fullText.length / 4);\n\n return {\n schemaName: name,\n summary,\n synonymWarnings,\n tokenEstimate,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Convenience: get schemas for a set of phase-relevant schema names\n// ---------------------------------------------------------------------------\n\n/** Maps pipeline phase → schema names relevant to that phase */\nexport const PHASE_SCHEMA_NAMES: Partial<Record<string, readonly SchemaName[]>> = {\n workflow: ['workflow_step', 'workflow_definition', 'workflow_field', 'workflow_action'],\n pages: ['navigation_item'],\n dashboard: ['navigation_item'],\n polish: ['navigation_item'],\n auth: ['auth_config'],\n};\n\n/**\n * Returns a compact schema reference block for injection into specialist prompts.\n * Called by handleBuildSpecialistPrompt — gives specialists the canonical\n * schema contract without requiring MCP access in sub-agents.\n */\nexport function buildSchemaReferenceBlock(phase: string): string | null {\n const schemaNames = PHASE_SCHEMA_NAMES[phase];\n if (!schemaNames || schemaNames.length === 0) return null;\n\n const sections: string[] = [\n 'CANONICAL_SCHEMA_REFERENCE (authoritative — use exact field names below):',\n '',\n ];\n\n for (const name of schemaNames) {\n const result = handleGetSchema({ schemaName: name });\n if ('error' in result) continue;\n\n sections.push(result.summary);\n\n if (result.synonymWarnings.length > 0) {\n sections.push(' Common mistakes to avoid:');\n sections.push(...result.synonymWarnings);\n }\n sections.push('');\n }\n\n return sections.join('\\n');\n}\n\n// ---------------------------------------------------------------------------\n// MCP tool registration\n// ---------------------------------------------------------------------------\n\nexport function registerGetSchemaTool(server: McpServer): void {\n server.tool(\n 'stackwright_pro_get_schema',\n 'Returns a compact, LLM-friendly summary of a canonical schema derived programmatically from Zod. Includes required/optional fields, types, constraints, and \"did you mean?\" warnings for common field-name drift. Use before authoring YAML to confirm exact field names.',\n {\n schemaName: z\n .enum(SUPPORTED_SCHEMA_NAMES)\n .describe(\n 'Schema to summarize. One of: workflow_step, workflow_definition, workflow_field, workflow_action, navigation_item, auth_config'\n ),\n },\n async ({ schemaName }) => {\n const result = handleGetSchema({ schemaName });\n if ('error' in result) {\n return {\n content: [{ type: 'text', text: JSON.stringify({ error: result.error }) }],\n isError: true,\n };\n }\n return {\n content: [\n {\n type: 'text',\n text: JSON.stringify(result, null, 2),\n },\n ],\n };\n }\n );\n}\n","/**\n * Validate YAML Fragment Tool — \"Sinks Not Pipes\"\n *\n * Parses a YAML snippet and validates it against the canonical Zod schema for\n * the named schema type. Returns actionable errors with \"did you mean?\"\n * suggestions for the most common drift patterns seen across 5 raft postmortems.\n *\n * Otters should call this BEFORE writing YAML to disk — catching drift in-session\n * is orders of magnitude cheaper than a failed prebuild after the raft completes.\n */\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\nimport { load as yamlLoad } from 'js-yaml';\nimport {\n WorkflowStepSchema,\n WorkflowDefinitionSchema,\n WorkflowFieldSchema,\n WorkflowActionSchema,\n authConfigSchema,\n} from '@stackwright-pro/types';\n\n// ---------------------------------------------------------------------------\n// Supported schema names\n// ---------------------------------------------------------------------------\n\nexport const SUPPORTED_SCHEMA_NAMES = [\n 'workflow_step',\n 'workflow_definition',\n 'workflow_field',\n 'workflow_action',\n 'navigation_item',\n 'auth_config',\n] as const;\n\nexport type SchemaName = (typeof SUPPORTED_SCHEMA_NAMES)[number];\n\n// ---------------------------------------------------------------------------\n// Inline navigation schema (not exported from @stackwright-pro/types —\n// mirrors @stackwright/types NavigationItem shape without adding the dep)\n// ---------------------------------------------------------------------------\n\nconst NavigationLinkSchema: z.ZodType<unknown> = z.lazy(() =>\n z.object({\n label: z.string().min(1),\n href: z.string().min(1),\n children: z.array(NavigationLinkSchema).optional(),\n })\n);\n\nconst NavigationSectionSchema = z.object({\n section: z.string().min(1),\n items: z.array(NavigationLinkSchema),\n});\n\nconst NavigationItemSchema = z.union([\n NavigationLinkSchema as z.ZodObject<any>,\n NavigationSectionSchema,\n]);\n\n// ---------------------------------------------------------------------------\n// Known field-name drift patterns from 5 raft postmortems.\n// Key: wrong field name → correct usage hint.\n// ---------------------------------------------------------------------------\n\nexport const FIELD_SYNONYMS: Record<SchemaName, Record<string, string>> = {\n workflow_step: {\n title: 'label',\n name: 'label (at step level, use label: not name:)',\n description: 'message (use message: for step-level descriptive text)',\n style: 'theme.variant — use theme: { variant: \"primary\" } not style: \"primary\"',\n transitions:\n 'on_submit.transition — use on_submit: { transition: \"next_step\" } not transitions: [{then:...}]',\n },\n workflow_definition: {\n title: 'label',\n name: 'label (workflow-level label, not name:)',\n description: 'description is valid here — but message: is not (that is a step field)',\n type: 'id (workflows are identified by id:, not type:)',\n },\n workflow_field: {\n id: 'name (field-level identifier is name:, NOT id:)',\n title: 'label (field display text is label:, NOT title:)',\n description: 'placeholder or label (no description field on WorkflowField)',\n },\n workflow_action: {\n style: 'theme.variant — use theme: { variant: \"primary\" } not style: \"primary\"',\n then: 'transition (action-level next-step is transition:, NOT then:)',\n on_click: 'action: \"service:...\" (extract the service reference from on_click.action)',\n title: 'label (action display text is label:, NOT title:)',\n name: 'label (action display text is label:, NOT name:)',\n },\n navigation_item: {\n children:\n 'items (NavigationSection uses items: not children: — also use section: instead of label: for section headers)',\n label:\n 'section (if this is a nav group/section, use section: \"Title\" and items: [...], not label:)',\n title: 'section (NavigationSection header field is section:, NOT title:)',\n },\n auth_config: {\n method: 'type — auth config discriminates on type: \"oidc\" | \"pki\", NOT method:',\n provider_type: 'type — use type: \"oidc\" or type: \"pki\"',\n strategy: 'type — top-level discriminator is type:, not strategy:',\n },\n};\n\n// ---------------------------------------------------------------------------\n// Schema registry\n// ---------------------------------------------------------------------------\n\nfunction getSchema(name: SchemaName): z.ZodTypeAny {\n switch (name) {\n case 'workflow_step':\n return WorkflowStepSchema;\n case 'workflow_definition':\n return WorkflowDefinitionSchema;\n case 'workflow_field':\n return WorkflowFieldSchema;\n case 'workflow_action':\n return WorkflowActionSchema;\n case 'navigation_item':\n return NavigationItemSchema;\n case 'auth_config':\n return authConfigSchema;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Validation error enrichment\n// ---------------------------------------------------------------------------\n\nexport interface ValidationError {\n path: string;\n message: string;\n didYouMean?: string;\n}\n\nexport interface ValidateYamlFragmentInput {\n schemaName: string;\n yaml: string;\n}\n\nexport interface ValidateYamlFragmentResult {\n valid: boolean;\n errors?: ValidationError[];\n parseError?: string;\n}\n\nfunction enrichErrors(issues: z.ZodIssue[], synonyms: Record<string, string>): ValidationError[] {\n return issues.map((issue) => {\n const pathStr = issue.path.join('.');\n // Check if the last path segment is a known synonym\n const lastSegment = issue.path[issue.path.length - 1];\n const fieldName = typeof lastSegment === 'string' ? lastSegment : undefined;\n const didYouMean = fieldName ? synonyms[fieldName] : undefined;\n return {\n path: pathStr || '(root)',\n message: issue.message,\n ...(didYouMean ? { didYouMean } : {}),\n };\n });\n}\n\n// ---------------------------------------------------------------------------\n// Handler (exported for direct testing)\n// ---------------------------------------------------------------------------\n\nexport function handleValidateYamlFragment(\n input: ValidateYamlFragmentInput\n): ValidateYamlFragmentResult {\n const { schemaName, yaml } = input;\n\n // Guard: unknown schema name\n if (!(SUPPORTED_SCHEMA_NAMES as readonly string[]).includes(schemaName)) {\n return {\n valid: false,\n parseError: `Unknown schemaName: \"${schemaName}\". Supported: ${SUPPORTED_SCHEMA_NAMES.join(', ')}`,\n };\n }\n\n const name = schemaName as SchemaName;\n\n // Parse YAML\n let parsed: unknown;\n try {\n parsed = yamlLoad(yaml);\n } catch (err) {\n return {\n valid: false,\n parseError: `YAML parse error: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n // Validate against Zod schema\n const schema = getSchema(name);\n const result = schema.safeParse(parsed);\n\n if (result.success) {\n return { valid: true };\n }\n\n const synonyms = FIELD_SYNONYMS[name];\n const errors = enrichErrors(result.error.issues, synonyms);\n return { valid: false, errors };\n}\n\n// ---------------------------------------------------------------------------\n// MCP tool registration\n// ---------------------------------------------------------------------------\n\nexport function registerValidateYamlFragmentTool(server: McpServer): void {\n server.tool(\n 'stackwright_pro_validate_yaml_fragment',\n 'Validates a YAML snippet against a canonical Zod schema before writing to disk. Returns actionable errors with \"did you mean?\" suggestions for common field-name drift (title vs label, id vs name, style vs theme.variant, etc.). Call this before stackwright_pro_safe_write to catch schema violations in-session.',\n {\n schemaName: z\n .enum(SUPPORTED_SCHEMA_NAMES)\n .describe(\n 'Schema to validate against. One of: workflow_step, workflow_definition, workflow_field, workflow_action, navigation_item, auth_config'\n ),\n yaml: z.string().describe('YAML string to validate (can also be JSON — js-yaml parses both)'),\n },\n async ({ schemaName, yaml }) => {\n const result = handleValidateYamlFragment({ schemaName, yaml });\n return {\n content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],\n };\n }\n );\n}\n"],"mappings":"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","names":["z","z","authConfigSchema","z","WorkflowStepSchema","WorkflowDefinitionSchema","WorkflowFieldSchema","WorkflowActionSchema","authConfigSchema","z","z","NavigationLinkSchema","z","NavigationSectionSchema","NavigationItemSchema"]}