Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .strray/state/state.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"votingHistory": [],
"metrics": {
"totalVotes": 0,
"successfulVotes": 0,
"failedVotes": 0,
"averageConfidence": 0,
"strategyUsage": {
"majority_vote": 0,
"consensus": 0,
"expert_priority": 0
},
"agentParticipation": {},
"averageVoterTurnout": 0
},
"exportedAt": "2026-05-15T11:20:11.148Z"
}
10 changes: 10 additions & 0 deletions docs/guards/Codify-Test-Coverage-Expansion-pattern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Guard: Codify Test Coverage Expansion pattern

Test Coverage Expansion detected across 110 sessions (avg confidence: 95%). 7 new test files added. Test-first or test-alongside development — covering new code as it ships.

## Evidence
+ src/__tests__/e2e/inference-e2e.test.ts
+ src/__tests__/integration/inference-pipeline.test.ts
+ src/__tests__/unit/inference/deploy-verifier.test.ts
+ src/__tests__/unit/inference/inference-accumulator.test.ts
+ src/__tests__/unit/inference/inference-cycle.test.ts
288 changes: 288 additions & 0 deletions docs/reflections/deep/release-v1.22.46-to-head-2026-05-15.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@
"test:full-suite": "npm run typecheck && npm run test:integration-all && npm run test:performance-all && npm run test:agents-all && npm run test:infrastructure && npm run test:root",
"postinstall": "node scripts/node/postinstall.cjs",
"setup-dev": "node scripts/node/setup-dev.cjs",
"debug:inference": "node dist/cli/index.js inference:debug",
"debug:inference:force": "STRRAY_FORCE_MCP_GOVERNANCE=true STRRAY_DEV_PATH=dist node dist/cli/index.js inference:run --force",
"prepare-consumer": "node scripts/node/prepare-consumer.cjs",
"typecheck": "tsc --noEmit",
"lint": "eslint -c tests/config/eslint.config.js src",
Expand Down
181 changes: 175 additions & 6 deletions src/inference/inference-cycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getGovernanceIntegration, type GovernanceVoteResult } from "../integrat
import { getAgentSpawn } from "../core/features-config.js";
import { agentSpawnGovernor } from "../orchestrator/agent-spawn-governor.js";
import { spawnGate } from "../core/opencode-spawn-gate.js";
import { mcpClientManager } from "../mcps/mcp-client.js";

export interface InferenceProposal {
id: string;
Expand Down Expand Up @@ -651,6 +652,11 @@ Respond with EXACTLY one of:
private async governProposalsInternal(
proposals: InferenceProposal[],
): Promise<InferenceCycleResult["votes"]> {
// PURE INDIVIDUAL MCP SKILLS PATH (user requirement)
if (process.env.STRRAY_FORCE_MCP_GOVERNANCE === 'true') {
return this.governProposalsWithIndividualSkills(proposals);
}

const coordinator = this.getCoordinator();
const sessionId = `inference-governance-${Date.now()}`;
const results: InferenceCycleResult["votes"] = [];
Expand Down Expand Up @@ -748,6 +754,94 @@ Respond with EXACTLY one of:
return results;
}

/**
* Pure individual knowledge-skill MCP path (when STRRAY_FORCE_MCP_GOVERNANCE=true).
* Each proposal is sent directly to the relevant skill servers via analyze_proposal.
* No orchestrator/architect prompt for the actual voting.
*/
private async governProposalsWithIndividualSkills(
proposals: InferenceProposal[],
): Promise<InferenceCycleResult["votes"]> {
process.stderr.write(`>>> [PURE MCP] Using individual knowledge-skill servers for governance (no architect prompt)\n`);

const results: InferenceCycleResult["votes"] = [];

const GOVERNANCE_AGENTS: Record<string, string[]> = {
fix: ["code-review", "security-audit"],
refactor: ["code-review", "security-audit"],
guard: ["code-review", "security-audit"],
automate: ["code-review", "security-audit"],
codify: ["code-review", "security-audit"],
};

for (const proposal of proposals) {
const agents = GOVERNANCE_AGENTS[proposal.type] ?? ["code-review", "security-audit"];
const skillVotes: any[] = [];

for (const agent of agents) {
try {
const skillResult = await mcpClientManager.callServerTool(agent, "analyze_proposal", {
proposalTitle: proposal.title,
proposalDescription: proposal.description,
evidence: proposal.evidence,
proposalType: proposal.type,
});

// Aggressive extraction of DECISION/CONFIDENCE/REASONING
let structured = "";
const contents = (skillResult as any)?.content || [];
for (const c of contents) {
if (c?.text && c.text.includes("DECISION:")) {
structured = c.text.trim();
break;
}
}
if (!structured) {
const full = JSON.stringify(skillResult);
const m = full.match(/DECISION:\s*(approve|reject|abstain)[\s\S]{0,300}?REASONING:[^\n"]*/i);
if (m) structured = m[0].trim();
}

skillVotes.push({
agent,
toolUsed: "analyze_proposal",
rawResponse: structured || JSON.stringify(skillResult),
structuredVote: structured || null,
});
} catch (err) {
skillVotes.push({
agent,
toolUsed: "analyze_proposal",
rawResponse: `error: ${err}`,
structuredVote: null,
});
}
}

// Aggregate
const approves = skillVotes.filter(v => v.structuredVote && v.structuredVote.includes("DECISION: approve")).length;
const rejects = skillVotes.filter(v => v.structuredVote && v.structuredVote.includes("DECISION: reject")).length;
const decision = approves > rejects ? "approve" : (rejects > approves ? "reject" : "abstain");

let avgConf = 0.75;
const confMatches = skillVotes.map(v => {
if (!v.structuredVote) return 0.75;
const m = v.structuredVote.match(/CONFIDENCE:\s*([0-9.]+)/);
return m ? parseFloat(m[1]) : 0.75;
});
if (confMatches.length > 0) avgConf = confMatches.reduce((a, b) => a + b, 0) / confMatches.length;

results.push({
proposalId: proposal.id,
decision: decision as any,
confidence: Math.round(avgConf * 100) / 100,
details: skillVotes.map(v => `${v.agent}: ${v.structuredVote?.split('\n')[0] || 'no structured vote'}`),
});
}

return results;
}

/**
* Merge internal and external governance votes.
* A proposal passes both oscillators — must be approved by internal AND external.
Expand Down Expand Up @@ -846,13 +940,43 @@ Respond with EXACTLY one of:
}

private async invokeAgentInternal(agentName: string, prompt: string): Promise<string> {
const isPureMcpMode = process.env.STRRAY_FORCE_MCP_GOVERNANCE === "true";

frameworkLogger.log("inference-cycle", "invoke-agent-internal", "info", {
agentName,
promptLength: prompt.length,
pureMcpMode: isPureMcpMode,
});

// === COMPLETE MCP DEBUG TRACING (when STRRAY_FORCE_MCP_GOVERNANCE=true) ===
if (isPureMcpMode) {
console.error(`\n${"=".repeat(80)}`);
console.error(`>>> MCP GOVERNANCE TRACE — AGENT: ${agentName.toUpperCase()}`);
console.error(`>>> TIMESTAMP: ${new Date().toISOString()}`);
console.error(`${"=".repeat(80)}`);
console.error(`>>> FULL PROMPT SENT TO ORCHESTRATOR:`);
console.error(prompt);
console.error(`\n>>> ARGS SENT TO orchestrate-task:`);
console.error(JSON.stringify({
description: prompt,
tasks: [{
id: `task-${Date.now()}`,
description: prompt,
type: agentName,
priority: "high",
}],
executionMode: "sequential",
}, null, 2));
console.error(`${"=".repeat(80)}\n`);
}

let mcpResponseText: string | undefined;
let mcpCallDuration = 0;

try {
const { mcpClientManager } = await import("../mcps/mcp-client.js");

const mcpStart = Date.now();
const result = await mcpClientManager.callServerTool("orchestrator", "orchestrate-task", {
description: prompt,
tasks: [{
Expand All @@ -863,38 +987,83 @@ Respond with EXACTLY one of:
}],
executionMode: "sequential",
});
mcpCallDuration = Date.now() - mcpStart;

const content = (result as { content?: Array<{ text?: string }> }).content;
let responseText = "";
mcpResponseText = "";
if (content && Array.isArray(content)) {
responseText = content.map((c: { text?: string }) => c.text ?? "").join("");
mcpResponseText = content.map((c: { text?: string }) => c.text ?? "").join("");
} else {
responseText = JSON.stringify(result);
mcpResponseText = JSON.stringify(result);
}

// === COMPLETE MCP DEBUG TRACING — RESPONSE ===
if (isPureMcpMode) {
console.error(`\n${"=".repeat(80)}`);
console.error(`>>> MCP RESPONSE FROM ORCHESTRATOR (took ${mcpCallDuration}ms)`);
console.error(`>>> RAW RESULT OBJECT:`);
console.error(JSON.stringify(result, null, 2));
console.error(`\n>>> EXTRACTED TEXT (first 5000 chars):`);
console.error(mcpResponseText.substring(0, 5000));
if (mcpResponseText.length > 5000) {
console.error(`... [truncated, total length: ${mcpResponseText.length} chars]`);
}
console.error(`${"=".repeat(80)}\n`);
}

// Only return if the response contains actual vote data (PROPOSAL blocks).
// Generic orchestration ACKs like "Tool orchestrate-task executed..." have no votes.
if (/PROPOSAL:\s*\d+/i.test(responseText)) {
return responseText;
if (/PROPOSAL:\s*\d+/i.test(mcpResponseText)) {
frameworkLogger.log("inference-cycle", "using-mcp-orchestrator-path", "info", { agentName });
console.error(`>>> USING MCP PATH (orchestrator/orchestrate-task) for ${agentName} — VALID PROPOSAL BLOCKS FOUND`);
return mcpResponseText;
}

frameworkLogger.log("inference-cycle", "mcp-no-votes", "info", {
agentName,
responsePreview: responseText.substring(0, 200),
responsePreview: mcpResponseText.substring(0, 200),
});

if (isPureMcpMode) {
console.error(`>>> MCP RESULT: No PROPOSAL: blocks detected in orchestrator response`);
}
} catch (mcpError) {
frameworkLogger.log("inference-cycle", "mcp-invocation-failed", "info", {
agentName,
error: String(mcpError),
});

if (isPureMcpMode) {
console.error(`\n${"=".repeat(80)}`);
console.error(`>>> MCP CALL FAILED for ${agentName}`);
console.error(`>>> ERROR: ${mcpError}`);
console.error(`${"=".repeat(80)}\n`);
}
}

if (this.agentInvoker) {
frameworkLogger.log("inference-cycle", "invoke-via-callback", "info", { agentName });
return this.agentInvoker(agentName, prompt);
}

// === PURE MCP TEST MODE ===
// Set STRRAY_FORCE_MCP_GOVERNANCE=true to run and test ONLY the MCP path.
// This completely disables the opencode run fallback for governance deliberation.
if (isPureMcpMode) {
frameworkLogger.log("inference-cycle", "pure-mcp-mode-no-opencode-fallback", "warning", {
agentName,
message: "OPENCODE FALLBACK BLOCKED — pure MCP governance test mode active",
});
console.error(`>>> PURE MCP MODE: opencode fallback disabled for ${agentName}`);
console.error(`>>> RETURNING mcpResponseText (may be empty or generic ACK)`);
return mcpResponseText || `MCP-ONLY: No usable PROPOSAL output from orchestrator for ${agentName}`;
}

return this.invokeViaOpencode(agentName, prompt);
}

private async invokeViaOpencode(agentName: string, prompt: string): Promise<string> {
console.error(`>>> USING LEGACY OPENCODE FALLBACK (opencode run --agent ${agentName})`);
// GATE: Centralized spawn gate — blocks all opencode spawning by default
spawnGate.assertAllowed("inference-cycle");

Expand Down
25 changes: 18 additions & 7 deletions src/mcps/config/server-config-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,22 @@ export class ServerConfigRegistry {
* Register all default server configurations
*/
private registerDefaultConfigs(): void {
// For consumer projects: default to node_modules/strray-ai/dist/
// For local dev: use STRRAY_DEV_PATH env var (e.g., "dist")
const basePath = process.env.STRRAY_DEV_PATH
? process.env.STRRAY_DEV_PATH
: 'node_modules/strray-ai/dist';
// Smart basePath detection for real server spawning
// Priority: STRRAY_DEV_PATH env > local dist (if running in source tree) > node_modules
let basePath = process.env.STRRAY_DEV_PATH || '';

if (!basePath) {
// Check if we are inside the stringray source (dist exists next to us)
const localDist = 'dist';
const fs = require('fs');
const path = require('path');
const candidate = path.join(process.cwd(), localDist, 'mcps', 'knowledge-skills', 'code-review.server.js');
if (fs.existsSync(candidate)) {
basePath = localDist;
} else {
basePath = 'node_modules/strray-ai/dist';
}
}

// Code Review Server
this.register({
Expand Down Expand Up @@ -62,11 +73,11 @@ export class ServerConfigRegistry {
timeout: 25000,
});

// Researcher Server
// Researcher Server (maps to project-analysis in current layout)
this.register({
serverName: 'researcher',
command: 'node',
args: [`${basePath}/mcps/researcher.server.js`],
args: [`${basePath}/mcps/project-analysis.server.js`],
timeout: 60000,
});

Expand Down
Loading
Loading