mstro-app 0.4.28 → 0.4.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-process.js +5 -1
- package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
- package/dist/server/cli/headless/haiku-assessments.d.ts.map +1 -1
- package/dist/server/cli/headless/haiku-assessments.js +20 -28
- package/dist/server/cli/headless/haiku-assessments.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +17 -3
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/prompt-builders.d.ts.map +1 -1
- package/dist/server/cli/prompt-builders.js +35 -19
- package/dist/server/cli/prompt-builders.js.map +1 -1
- package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-haiku.js +5 -30
- package/dist/server/mcp/bouncer-haiku.js.map +1 -1
- package/dist/server/mcp/security-analysis.d.ts.map +1 -1
- package/dist/server/mcp/security-analysis.js +19 -11
- package/dist/server/mcp/security-analysis.js.map +1 -1
- package/dist/server/services/deploy/headless-session-handler.d.ts.map +1 -1
- package/dist/server/services/deploy/headless-session-handler.js +61 -69
- package/dist/server/services/deploy/headless-session-handler.js.map +1 -1
- package/dist/server/services/pathUtils.d.ts.map +1 -1
- package/dist/server/services/pathUtils.js +46 -38
- package/dist/server/services/pathUtils.js.map +1 -1
- package/dist/server/services/plan/agent-loader.d.ts +20 -4
- package/dist/server/services/plan/agent-loader.d.ts.map +1 -1
- package/dist/server/services/plan/agent-loader.js +85 -16
- package/dist/server/services/plan/agent-loader.js.map +1 -1
- package/dist/server/services/plan/issue-retry.d.ts +0 -8
- package/dist/server/services/plan/issue-retry.d.ts.map +1 -1
- package/dist/server/services/plan/issue-retry.js +72 -63
- package/dist/server/services/plan/issue-retry.js.map +1 -1
- package/dist/server/services/plan/review-gate.js +16 -88
- package/dist/server/services/plan/review-gate.js.map +1 -1
- package/dist/server/services/websocket/git-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-handlers.js +6 -19
- package/dist/server/services/websocket/git-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-pr-handlers.js +5 -21
- package/dist/server/services/websocket/git-pr-handlers.js.map +1 -1
- package/dist/server/services/websocket/handlers/deploy-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/handlers/deploy-handlers.js +28 -33
- package/dist/server/services/websocket/handlers/deploy-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-board-handlers.js +31 -25
- package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-fix-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-fix-agent.js +11 -18
- package/dist/server/services/websocket/quality-fix-agent.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +13 -150
- package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
- package/package.json +1 -1
- package/server/cli/headless/claude-invoker-process.ts +5 -1
- package/server/cli/headless/haiku-assessments.ts +21 -28
- package/server/cli/headless/stall-assessor.ts +17 -3
- package/server/cli/prompt-builders.ts +34 -23
- package/server/mcp/bouncer-haiku.ts +5 -30
- package/server/mcp/security-analysis.ts +19 -12
- package/server/services/deploy/headless-session-handler.ts +75 -76
- package/server/services/pathUtils.ts +55 -42
- package/server/services/plan/agent-loader.ts +88 -15
- package/server/services/plan/issue-retry.ts +93 -68
- package/server/services/plan/review-gate.ts +13 -89
- package/server/services/websocket/git-handlers.ts +6 -18
- package/server/services/websocket/git-pr-handlers.ts +5 -20
- package/server/services/websocket/handlers/deploy-handlers.ts +34 -37
- package/server/services/websocket/plan-board-handlers.ts +36 -21
- package/server/services/websocket/quality-fix-agent.ts +10 -17
- package/server/services/websocket/quality-review-agent.ts +12 -149
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Agent Prompt Loader — loads review agent prompts from markdown files.
|
|
5
|
+
* Agent Prompt Loader — loads review agent prompts from Skills and markdown files.
|
|
6
6
|
*
|
|
7
7
|
* Resolution order (first match wins):
|
|
8
8
|
* 1. Board-level override: {boardDir}/agents/{agentName}.md
|
|
9
|
-
* 2.
|
|
9
|
+
* 2. Project Skill: {workingDir}/.claude/skills/{agentName}/SKILL.md
|
|
10
|
+
* 3. System default: cli/server/services/plan/agents/{agentName}.md
|
|
10
11
|
*
|
|
11
12
|
* Files use YAML frontmatter + markdown body with {{variable}} placeholders.
|
|
12
13
|
* Falls back to null when no file is found (caller should use hardcoded fallback).
|
|
@@ -34,40 +35,112 @@ function interpolate(template: string, variables: Record<string, string>): strin
|
|
|
34
35
|
});
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** Try to load and interpolate a prompt file. Returns null on failure. */
|
|
39
|
+
function tryLoadFile(filePath: string, variables: Record<string, string>): string | null {
|
|
40
|
+
if (!existsSync(filePath)) return null;
|
|
41
|
+
try {
|
|
42
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
43
|
+
return interpolate(stripFrontmatter(raw), variables);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the project root by walking up from a directory looking for `.claude/skills/`.
|
|
51
|
+
* Returns the `.claude/skills/` path if found, or null.
|
|
52
|
+
*/
|
|
53
|
+
function findSkillsDir(startDir: string): string | null {
|
|
54
|
+
let dir = startDir;
|
|
55
|
+
for (let i = 0; i < 10; i++) {
|
|
56
|
+
const candidate = join(dir, '.claude', 'skills');
|
|
57
|
+
if (existsSync(candidate)) return candidate;
|
|
58
|
+
const parent = dirname(dir);
|
|
59
|
+
if (parent === dir) break;
|
|
60
|
+
dir = parent;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
37
65
|
/**
|
|
38
66
|
* Load an agent prompt by name with layered resolution.
|
|
39
67
|
*
|
|
40
|
-
* @param agentName
|
|
41
|
-
* @param variables
|
|
42
|
-
* @param boardDir
|
|
68
|
+
* @param agentName - The agent file name without extension (e.g., "review-code")
|
|
69
|
+
* @param variables - Key-value map for {{variable}} substitution
|
|
70
|
+
* @param boardDir - Optional board directory for board-level overrides
|
|
71
|
+
* @param workingDir - Optional working directory for project-level Skill resolution
|
|
43
72
|
* @returns The interpolated prompt string, or null if no agent file found
|
|
44
73
|
*/
|
|
45
74
|
export function loadAgentPrompt(
|
|
46
75
|
agentName: string,
|
|
47
76
|
variables: Record<string, string>,
|
|
48
77
|
boardDir?: string | null,
|
|
78
|
+
workingDir?: string | null,
|
|
49
79
|
): string | null {
|
|
50
80
|
const fileName = `${agentName}.md`;
|
|
51
81
|
|
|
52
82
|
// 1. Board-level override
|
|
53
83
|
if (boardDir) {
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
84
|
+
const result = tryLoadFile(join(boardDir, 'agents', fileName), variables);
|
|
85
|
+
if (result) return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. Project Skill: {workingDir}/.claude/skills/{agentName}/SKILL.md
|
|
89
|
+
if (workingDir) {
|
|
90
|
+
const skillsDir = findSkillsDir(workingDir);
|
|
91
|
+
if (skillsDir) {
|
|
92
|
+
const result = tryLoadFile(join(skillsDir, agentName, 'SKILL.md'), variables);
|
|
93
|
+
if (result) return result;
|
|
60
94
|
}
|
|
61
95
|
}
|
|
62
96
|
|
|
63
|
-
//
|
|
64
|
-
|
|
97
|
+
// 3. System default
|
|
98
|
+
return tryLoadFile(join(SYSTEM_AGENTS_DIR, fileName), variables);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Load a Skill template body by name, stripping frontmatter.
|
|
103
|
+
* Looks in {workingDir}/.claude/skills/{skillName}/SKILL.md first,
|
|
104
|
+
* then falls back to the system agents directory.
|
|
105
|
+
*
|
|
106
|
+
* @param skillName - The skill directory name (e.g., "code-review")
|
|
107
|
+
* @param workingDir - Working directory for project-level Skill resolution
|
|
108
|
+
* @returns Raw template body (no frontmatter), or null if not found
|
|
109
|
+
*/
|
|
110
|
+
export function loadSkillTemplate(skillName: string, workingDir?: string): string | null {
|
|
111
|
+
if (workingDir) {
|
|
112
|
+
const skillsDir = findSkillsDir(workingDir);
|
|
113
|
+
if (skillsDir) {
|
|
114
|
+
const path = join(skillsDir, skillName, 'SKILL.md');
|
|
115
|
+
if (existsSync(path)) {
|
|
116
|
+
try {
|
|
117
|
+
return stripFrontmatter(readFileSync(path, 'utf-8'));
|
|
118
|
+
} catch { /* fall through */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fallback: system agents directory
|
|
124
|
+
const systemPath = join(SYSTEM_AGENTS_DIR, `${skillName}.md`);
|
|
65
125
|
if (existsSync(systemPath)) {
|
|
66
126
|
try {
|
|
67
|
-
|
|
68
|
-
return interpolate(stripFrontmatter(raw), variables);
|
|
127
|
+
return stripFrontmatter(readFileSync(systemPath, 'utf-8'));
|
|
69
128
|
} catch { /* return null */ }
|
|
70
129
|
}
|
|
71
130
|
|
|
72
131
|
return null;
|
|
73
132
|
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Load a Skill template and interpolate variables.
|
|
136
|
+
* Convenience wrapper combining loadSkillTemplate + interpolation.
|
|
137
|
+
*/
|
|
138
|
+
export function loadSkillPrompt(
|
|
139
|
+
skillName: string,
|
|
140
|
+
variables: Record<string, string>,
|
|
141
|
+
workingDir?: string,
|
|
142
|
+
): string | null {
|
|
143
|
+
const template = loadSkillTemplate(skillName, workingDir);
|
|
144
|
+
if (!template) return null;
|
|
145
|
+
return interpolate(template, variables);
|
|
146
|
+
}
|
|
@@ -76,6 +76,93 @@ export interface IssueRunnerConfig {
|
|
|
76
76
|
* 2. Signal crash → fresh start with preserved tool results
|
|
77
77
|
* 3. Premature completion → resume session with "continue"
|
|
78
78
|
*/
|
|
79
|
+
/** Build the default "aborted" fallback result. */
|
|
80
|
+
function abortedResult(bestResult: SessionResult | null): SessionResult {
|
|
81
|
+
return bestResult ?? {
|
|
82
|
+
completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
|
|
83
|
+
error: 'Execution stopped by user',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Create a HeadlessRunner configured for the current retry iteration. */
|
|
88
|
+
function createRunner(
|
|
89
|
+
config: IssueRunnerConfig,
|
|
90
|
+
state: IssueRetryState,
|
|
91
|
+
useResume: boolean,
|
|
92
|
+
resumeSessionId: string | undefined,
|
|
93
|
+
): HeadlessRunner {
|
|
94
|
+
return new HeadlessRunner({
|
|
95
|
+
workingDir: config.workingDir,
|
|
96
|
+
directPrompt: state.currentPrompt,
|
|
97
|
+
stallWarningMs: config.stallWarningMs,
|
|
98
|
+
stallKillMs: config.stallKillMs,
|
|
99
|
+
stallHardCapMs: config.stallHardCapMs,
|
|
100
|
+
stallMaxExtensions: config.stallMaxExtensions,
|
|
101
|
+
verbose: true,
|
|
102
|
+
continueSession: useResume,
|
|
103
|
+
claudeSessionId: resumeSessionId,
|
|
104
|
+
outputCallback: config.outputCallback,
|
|
105
|
+
onToolTimeout: (cp: ExecutionCheckpoint) => {
|
|
106
|
+
state.checkpoint = cp;
|
|
107
|
+
},
|
|
108
|
+
extraEnv: config.extraEnv,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Wire the abort signal to clean up the runner. Returns a cleanup function. */
|
|
113
|
+
function wireAbortSignal(
|
|
114
|
+
runner: HeadlessRunner,
|
|
115
|
+
abortSignal: AbortSignal | undefined,
|
|
116
|
+
): (() => void) | null {
|
|
117
|
+
if (!abortSignal) return null;
|
|
118
|
+
|
|
119
|
+
const abortHandler = () => { runner.cleanup(); };
|
|
120
|
+
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
|
121
|
+
return () => abortSignal.removeEventListener('abort', abortHandler);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Run a single iteration: spawn runner, await result, evaluate retry.
|
|
126
|
+
* Returns { result, shouldRetry } — caller loops while shouldRetry is true.
|
|
127
|
+
* Returns null if aborted (caller should return abortedResult).
|
|
128
|
+
*/
|
|
129
|
+
async function runSingleAttempt(
|
|
130
|
+
config: IssueRunnerConfig,
|
|
131
|
+
state: IssueRetryState,
|
|
132
|
+
): Promise<{ result: SessionResult; shouldRetry: boolean } | null> {
|
|
133
|
+
state.checkpoint = null;
|
|
134
|
+
|
|
135
|
+
const useResume = !!state.lastSessionId;
|
|
136
|
+
const resumeSessionId = state.lastSessionId;
|
|
137
|
+
state.lastSessionId = undefined;
|
|
138
|
+
|
|
139
|
+
const runner = createRunner(config, state, useResume, resumeSessionId);
|
|
140
|
+
|
|
141
|
+
if (config.abortSignal?.aborted) {
|
|
142
|
+
runner.cleanup();
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const removeAbortListener = wireAbortSignal(runner, config.abortSignal);
|
|
146
|
+
|
|
147
|
+
const result = await runner.run();
|
|
148
|
+
removeAbortListener?.();
|
|
149
|
+
|
|
150
|
+
if (config.abortSignal?.aborted) return null;
|
|
151
|
+
|
|
152
|
+
// Track best result for fallback selection
|
|
153
|
+
if (!state.bestResult || scoreResult(result) > scoreResult(state.bestResult)) {
|
|
154
|
+
state.bestResult = result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Evaluate retry strategies in priority order
|
|
158
|
+
const shouldRetry =
|
|
159
|
+
tryToolTimeoutRetry(state, result, config) ||
|
|
160
|
+
trySignalCrashRetry(state, result, config) ||
|
|
161
|
+
await tryPrematureCompletionRetry(state, result, config);
|
|
162
|
+
|
|
163
|
+
return { result, shouldRetry };
|
|
164
|
+
}
|
|
165
|
+
|
|
79
166
|
export async function runIssueWithRetry(config: IssueRunnerConfig): Promise<SessionResult> {
|
|
80
167
|
const state: IssueRetryState = {
|
|
81
168
|
currentPrompt: config.prompt,
|
|
@@ -90,77 +177,15 @@ export async function runIssueWithRetry(config: IssueRunnerConfig): Promise<Sess
|
|
|
90
177
|
let result: SessionResult | undefined;
|
|
91
178
|
|
|
92
179
|
while (state.retryNumber <= MAX_ISSUE_RETRIES) {
|
|
93
|
-
|
|
94
|
-
if (config.abortSignal?.aborted) {
|
|
95
|
-
return state.bestResult ?? {
|
|
96
|
-
completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
|
|
97
|
-
error: 'Execution stopped by user',
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Clear checkpoint from prior iteration
|
|
102
|
-
state.checkpoint = null;
|
|
103
|
-
|
|
104
|
-
// Determine resume strategy
|
|
105
|
-
const useResume = !!state.lastSessionId;
|
|
106
|
-
const resumeSessionId = state.lastSessionId;
|
|
107
|
-
state.lastSessionId = undefined;
|
|
108
|
-
|
|
109
|
-
const runner = new HeadlessRunner({
|
|
110
|
-
workingDir: config.workingDir,
|
|
111
|
-
directPrompt: state.currentPrompt,
|
|
112
|
-
stallWarningMs: config.stallWarningMs,
|
|
113
|
-
stallKillMs: config.stallKillMs,
|
|
114
|
-
stallHardCapMs: config.stallHardCapMs,
|
|
115
|
-
stallMaxExtensions: config.stallMaxExtensions,
|
|
116
|
-
verbose: true,
|
|
117
|
-
continueSession: useResume,
|
|
118
|
-
claudeSessionId: resumeSessionId,
|
|
119
|
-
outputCallback: config.outputCallback,
|
|
120
|
-
onToolTimeout: (cp: ExecutionCheckpoint) => {
|
|
121
|
-
state.checkpoint = cp;
|
|
122
|
-
},
|
|
123
|
-
extraEnv: config.extraEnv,
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
// Wire abort signal to kill the runner's processes
|
|
127
|
-
const abortHandler = () => { runner.cleanup(); };
|
|
128
|
-
if (config.abortSignal) {
|
|
129
|
-
if (config.abortSignal.aborted) {
|
|
130
|
-
runner.cleanup();
|
|
131
|
-
return state.bestResult ?? {
|
|
132
|
-
completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
|
|
133
|
-
error: 'Execution stopped by user',
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
config.abortSignal.addEventListener('abort', abortHandler, { once: true });
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
result = await runner.run();
|
|
180
|
+
if (config.abortSignal?.aborted) return abortedResult(state.bestResult);
|
|
140
181
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// If aborted during run, return immediately
|
|
145
|
-
if (config.abortSignal?.aborted) {
|
|
146
|
-
return state.bestResult ?? result ?? {
|
|
147
|
-
completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
|
|
148
|
-
error: 'Execution stopped by user',
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Track best result for fallback selection
|
|
153
|
-
if (!state.bestResult || scoreResult(result) > scoreResult(state.bestResult)) {
|
|
154
|
-
state.bestResult = result;
|
|
182
|
+
const attempt = await runSingleAttempt(config, state);
|
|
183
|
+
if (!attempt) {
|
|
184
|
+
return state.bestResult ?? result ?? abortedResult(null);
|
|
155
185
|
}
|
|
156
186
|
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
if (trySignalCrashRetry(state, result, config)) continue;
|
|
160
|
-
if (await tryPrematureCompletionRetry(state, result, config)) continue;
|
|
161
|
-
|
|
162
|
-
// No retry needed — break out
|
|
163
|
-
break;
|
|
187
|
+
result = attempt.result;
|
|
188
|
+
if (!attempt.shouldRetry) break;
|
|
164
189
|
}
|
|
165
190
|
|
|
166
191
|
return result ?? state.bestResult ?? {
|
|
@@ -50,7 +50,7 @@ export async function reviewIssue(options: ReviewIssueOptions): Promise<ReviewRe
|
|
|
50
50
|
const issueType: ReviewResult['issueType'] = isCodeTask ? 'code' : 'non-code';
|
|
51
51
|
|
|
52
52
|
try {
|
|
53
|
-
const prompt = buildReviewPrompt(issue, pmDir, outputPath, isCodeTask, reviewCriteria, boardDir);
|
|
53
|
+
const prompt = buildReviewPrompt(issue, pmDir, outputPath, isCodeTask, reviewCriteria, boardDir, workingDir);
|
|
54
54
|
|
|
55
55
|
const runner = new HeadlessRunner({
|
|
56
56
|
workingDir,
|
|
@@ -188,6 +188,7 @@ function buildReviewPrompt(
|
|
|
188
188
|
isCodeTask: boolean,
|
|
189
189
|
reviewCriteria?: string,
|
|
190
190
|
boardDir?: string | null,
|
|
191
|
+
workingDir?: string,
|
|
191
192
|
): string {
|
|
192
193
|
const criteria = issue.acceptanceCriteria
|
|
193
194
|
.map(c => `- [${c.checked ? 'x' : ' '}] ${c.text}`)
|
|
@@ -205,115 +206,38 @@ function buildReviewPrompt(
|
|
|
205
206
|
? 'Read each modified file listed above'
|
|
206
207
|
: 'Read the output file and issue spec at the paths above';
|
|
207
208
|
|
|
208
|
-
|
|
209
|
+
const result = loadAgentPrompt('review-custom', {
|
|
209
210
|
issue_id: issue.id,
|
|
210
211
|
issue_title: issue.title,
|
|
211
212
|
context_section: contextSection,
|
|
212
213
|
acceptance_criteria: criteriaStr,
|
|
213
214
|
review_criteria: reviewCriteria,
|
|
214
215
|
read_instruction: readInstruction,
|
|
215
|
-
}, boardDir
|
|
216
|
+
}, boardDir, workingDir);
|
|
217
|
+
if (result) return result;
|
|
216
218
|
}
|
|
217
219
|
|
|
218
220
|
if (isCodeTask) {
|
|
219
|
-
|
|
221
|
+
const result = loadAgentPrompt('review-code', {
|
|
220
222
|
issue_id: issue.id,
|
|
221
223
|
issue_title: issue.title,
|
|
222
224
|
files_modified: filesModified,
|
|
223
225
|
acceptance_criteria: criteriaStr,
|
|
224
226
|
output_path: outputPath,
|
|
225
|
-
}, boardDir
|
|
227
|
+
}, boardDir, workingDir);
|
|
228
|
+
if (result) return result;
|
|
226
229
|
}
|
|
227
230
|
|
|
228
|
-
|
|
231
|
+
const result = loadAgentPrompt('review-quality', {
|
|
229
232
|
issue_id: issue.id,
|
|
230
233
|
issue_title: issue.title,
|
|
231
234
|
output_path: outputPath,
|
|
232
235
|
issue_spec_path: issueSpecPath,
|
|
233
236
|
acceptance_criteria: criteriaStr,
|
|
234
|
-
}, boardDir
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
// ── Hardcoded fallbacks (used when agent files are missing) ──────────
|
|
238
|
-
|
|
239
|
-
function buildCodeFallback(issue: Issue, filesModified: string, criteria: string, outputPath: string): string {
|
|
240
|
-
return `You are a reviewer. Review the work done for issue ${issue.id}: ${issue.title}.
|
|
241
|
-
|
|
242
|
-
## Files Modified
|
|
243
|
-
${filesModified}
|
|
244
|
-
|
|
245
|
-
## Acceptance Criteria
|
|
246
|
-
${criteria}
|
|
247
|
-
|
|
248
|
-
## Instructions
|
|
249
|
-
1. Read each modified file listed above
|
|
250
|
-
2. Check if all acceptance criteria are met by the changes
|
|
251
|
-
3. Evaluate the quality of the changes:
|
|
252
|
-
- For source code files: look for obvious bugs, security vulnerabilities, or code quality issues
|
|
253
|
-
- For content files (markdown, docs, config, copy): check for accuracy, completeness, and appropriate structure
|
|
254
|
-
4. Check if the output artifact exists at: ${outputPath}
|
|
255
|
-
|
|
256
|
-
Output EXACTLY one JSON object on its own line (no markdown fencing):
|
|
257
|
-
{"passed": true, "checks": [{"name": "criteria_met", "passed": true, "details": "..."}]}
|
|
258
|
-
|
|
259
|
-
Include checks for: criteria_met, code_quality, no_obvious_bugs.`;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function buildQualityFallback(issue: Issue, outputPath: string, issueSpecPath: string, criteria: string): string {
|
|
263
|
-
return `You are a quality reviewer. Review the work done for issue ${issue.id}: ${issue.title}.
|
|
264
|
-
|
|
265
|
-
## Output File
|
|
266
|
-
${outputPath}
|
|
267
|
-
|
|
268
|
-
## Issue Spec
|
|
269
|
-
${issueSpecPath}
|
|
237
|
+
}, boardDir, workingDir);
|
|
238
|
+
if (result) return result;
|
|
270
239
|
|
|
271
|
-
|
|
272
|
-
${
|
|
273
|
-
|
|
274
|
-
## Instructions
|
|
275
|
-
1. Read the output file at the path above
|
|
276
|
-
2. Read the full issue spec to understand the original requirements and intent
|
|
277
|
-
3. Evaluate the output against ALL of the following dimensions:
|
|
278
|
-
|
|
279
|
-
### Acceptance Criteria
|
|
280
|
-
- Are all acceptance criteria met? Check each one individually.
|
|
281
|
-
|
|
282
|
-
### Content Quality
|
|
283
|
-
- Is the content accurate, well-reasoned, and free of factual errors?
|
|
284
|
-
- Is it written clearly with appropriate structure and organization?
|
|
285
|
-
- Does it have sufficient depth and detail for its purpose?
|
|
286
|
-
- Is the tone and style appropriate for the intended audience?
|
|
287
|
-
|
|
288
|
-
### Completeness
|
|
289
|
-
- Does the output fully address what was requested in the issue spec?
|
|
290
|
-
- Are there obvious gaps, missing sections, or incomplete thoughts?
|
|
291
|
-
- If the issue requested specific deliverables (e.g., a plan, analysis, document), are all deliverables present?
|
|
292
|
-
|
|
293
|
-
Output EXACTLY one JSON object on its own line (no markdown fencing):
|
|
294
|
-
{"passed": true, "checks": [{"name": "criteria_met", "passed": true, "details": "..."}]}
|
|
295
|
-
|
|
296
|
-
Include checks for: criteria_met, output_quality, completeness.`;
|
|
240
|
+
// Should not reach here if Skills are installed, but provide a minimal fallback
|
|
241
|
+
return `You are a reviewer. Review the work done for issue ${issue.id}: ${issue.title}.\n\n## Acceptance Criteria\n${criteriaStr}\n\nOutput EXACTLY one JSON object on its own line (no markdown fencing):\n{"passed": true, "checks": [{"name": "criteria_met", "passed": true, "details": "..."}]}`;
|
|
297
242
|
}
|
|
298
243
|
|
|
299
|
-
function buildCustomFallback(issue: Issue, contextSection: string, criteria: string, reviewCriteria: string, readInstruction: string): string {
|
|
300
|
-
return `You are a reviewer. Review the work done for issue ${issue.id}: ${issue.title}.
|
|
301
|
-
${contextSection}
|
|
302
|
-
|
|
303
|
-
## Acceptance Criteria
|
|
304
|
-
${criteria}
|
|
305
|
-
|
|
306
|
-
## Review Criteria
|
|
307
|
-
${reviewCriteria}
|
|
308
|
-
|
|
309
|
-
## Instructions
|
|
310
|
-
1. ${readInstruction}
|
|
311
|
-
2. Check if all acceptance criteria are met — evaluate each criterion individually
|
|
312
|
-
3. Evaluate thoroughly against the review criteria above
|
|
313
|
-
4. Consider the overall quality of the work: does it fully address the issue's intent, is it well-structured, and is it ready to ship?
|
|
314
|
-
|
|
315
|
-
Output EXACTLY one JSON object on its own line (no markdown fencing):
|
|
316
|
-
{"passed": true, "checks": [{"name": "criteria_met", "passed": true, "details": "..."}]}
|
|
317
|
-
|
|
318
|
-
Include checks for: criteria_met, review_criteria.`;
|
|
319
|
-
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
2
|
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
3
|
|
|
4
|
+
import { loadSkillPrompt } from '../plan/agent-loader.js';
|
|
4
5
|
import { handleGitCheckout, handleGitCreateBranch, handleGitDeleteBranch, handleGitListBranches } from './git-branch-handlers.js';
|
|
5
6
|
import { handleGitCommitDiff, handleGitDiff, handleGitShowCommit } from './git-diff-handlers.js';
|
|
6
7
|
import { handleGitDiscoverRepos, handleGitLog, handleGitSetDirectory } from './git-log-handlers.js';
|
|
@@ -195,25 +196,12 @@ async function handleGitCommitWithAI(ctx: HandlerContext, ws: WSContext, msg: We
|
|
|
195
196
|
const diffResult = await executeGitCommand(['diff', '--cached'], workingDir);
|
|
196
197
|
const logResult = await executeGitCommand(['log', '--oneline', '-5'], workingDir);
|
|
197
198
|
|
|
198
|
-
const
|
|
199
|
+
const recentCommits = logResult.stdout.trim() || 'No recent commits';
|
|
200
|
+
const stagedFiles = staged.map(f => `${f.status} ${f.path}`).join('\n');
|
|
201
|
+
const diff = truncateDiff(diffResult.stdout);
|
|
199
202
|
|
|
200
|
-
|
|
201
|
-
${
|
|
202
|
-
|
|
203
|
-
STAGED FILES:
|
|
204
|
-
${staged.map(f => `${f.status} ${f.path}`).join('\n')}
|
|
205
|
-
|
|
206
|
-
DIFF OF STAGED CHANGES:
|
|
207
|
-
${truncateDiff(diffResult.stdout)}
|
|
208
|
-
|
|
209
|
-
Generate a commit message following these rules:
|
|
210
|
-
1. First line: imperative mood, max 72 characters (e.g., "Add user authentication", "Fix memory leak in parser")
|
|
211
|
-
2. If the changes are complex, add a blank line then bullet points explaining the key changes
|
|
212
|
-
3. Focus on the "why" not just the "what"
|
|
213
|
-
4. Match the style of recent commits if possible
|
|
214
|
-
5. No emojis unless the repo already uses them
|
|
215
|
-
|
|
216
|
-
Respond with ONLY the commit message, nothing else.`;
|
|
203
|
+
const prompt = loadSkillPrompt('commit-message', { recentCommits, stagedFiles, diff }, workingDir)
|
|
204
|
+
?? `You are generating a git commit message for the following staged changes.\n\nRECENT COMMIT MESSAGES (for style reference):\n${recentCommits}\n\nSTAGED FILES:\n${stagedFiles}\n\nDIFF OF STAGED CHANGES:\n${diff}\n\nGenerate a commit message: imperative mood, max 72 characters, focus on "why". Respond with ONLY the commit message.`;
|
|
217
205
|
|
|
218
206
|
const result = await spawnHaikuWithPrompt(
|
|
219
207
|
prompt,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
2
|
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
3
|
|
|
4
|
+
import { loadSkillPrompt } from '../plan/agent-loader.js';
|
|
4
5
|
import { getPrBaseBranch, setPrBaseBranch } from '../settings.js';
|
|
5
6
|
import { detectGitProvider, executeGitCommand, spawnCheck, spawnHaikuWithPrompt, spawnWithOutput, stripCoauthorLines, truncateDiff } from './git-handlers.js';
|
|
6
7
|
import type { HandlerContext } from './handler-context.js';
|
|
@@ -272,27 +273,11 @@ async function handleGitGeneratePRDescription(ctx: HandlerContext, ws: WSContext
|
|
|
272
273
|
const diffResult = await executeGitCommand(['diff', `${compareRef}...HEAD`], workingDir);
|
|
273
274
|
const statResult = await executeGitCommand(['diff', `${compareRef}...HEAD`, '--stat'], workingDir);
|
|
274
275
|
|
|
275
|
-
const
|
|
276
|
+
const filesChanged = statResult.exitCode === 0 ? statResult.stdout.trim() : '';
|
|
277
|
+
const diff = truncateDiff(diffResult.exitCode === 0 ? diffResult.stdout : '');
|
|
276
278
|
|
|
277
|
-
|
|
278
|
-
${commits}
|
|
279
|
-
|
|
280
|
-
FILES CHANGED:
|
|
281
|
-
${statResult.exitCode === 0 ? statResult.stdout.trim() : ''}
|
|
282
|
-
|
|
283
|
-
DIFF:
|
|
284
|
-
${truncateDiff(diffResult.exitCode === 0 ? diffResult.stdout : '')}
|
|
285
|
-
|
|
286
|
-
Generate a pull request title and description following these rules:
|
|
287
|
-
1. TITLE: First line must be the PR title — imperative mood, under 70 characters
|
|
288
|
-
2. Leave a blank line after the title
|
|
289
|
-
3. BODY: Write a concise description in markdown with:
|
|
290
|
-
- A "## Summary" section with 1-3 bullet points explaining what changed and why
|
|
291
|
-
- Optionally a "## Details" section if the changes are complex
|
|
292
|
-
4. Focus on the "why" not just the "what"
|
|
293
|
-
5. No emojis
|
|
294
|
-
|
|
295
|
-
Respond with ONLY the title and description, nothing else.`;
|
|
279
|
+
const prompt = loadSkillPrompt('pr-description', { baseBranch, commits, filesChanged, diff }, workingDir)
|
|
280
|
+
?? `You are generating a pull request title and description.\n\nCOMMITS (${baseBranch}..HEAD):\n${commits}\n\nFILES CHANGED:\n${filesChanged}\n\nDIFF:\n${diff}\n\nGenerate PR title (imperative, <70 chars) then body with ## Summary (1-3 bullets). No emojis. Respond with ONLY the title and description.`;
|
|
296
281
|
|
|
297
282
|
const result = await spawnHaikuWithPrompt(
|
|
298
283
|
prompt,
|
|
@@ -111,6 +111,35 @@ function sendChunkedResponse(
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/** Validate the incoming deploy HTTP request data. Returns an error response body string or null if valid. */
|
|
115
|
+
function validateDeployRequest(
|
|
116
|
+
data: DeployHttpRequestData,
|
|
117
|
+
): { status: number; body: string } | null {
|
|
118
|
+
if (!data?.requestId || !data?.method || !data?.url || !data?.port) {
|
|
119
|
+
return { status: 400, body: 'Bad Request: missing required fields (requestId, method, url, port)' };
|
|
120
|
+
}
|
|
121
|
+
if (data.headers && containsHeaderInjection(data.headers)) {
|
|
122
|
+
return { status: 400, body: 'Bad Request: headers contain null bytes or CRLF injection' };
|
|
123
|
+
}
|
|
124
|
+
if (data.headers && calculateHeaderSize(data.headers) > MAX_HEADER_SIZE_BYTES) {
|
|
125
|
+
return { status: 431, body: 'Request Header Fields Too Large: total headers exceed 16KB' };
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Classify a fetch error into an HTTP status code and message. */
|
|
131
|
+
function classifyFetchError(error: unknown): { status: number; body: string } {
|
|
132
|
+
if (error instanceof Error) {
|
|
133
|
+
if (error.name === 'AbortError') {
|
|
134
|
+
return { status: 504, body: 'Gateway Timeout' };
|
|
135
|
+
}
|
|
136
|
+
if (isConnectionRefused(error)) {
|
|
137
|
+
return { status: 502, body: 'Bad Gateway: target server is not running' };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { status: 502, body: 'Bad Gateway' };
|
|
141
|
+
}
|
|
142
|
+
|
|
114
143
|
export async function handleDeployHttpRequest(
|
|
115
144
|
ctx: HandlerContext,
|
|
116
145
|
ws: WSContext,
|
|
@@ -118,34 +147,13 @@ export async function handleDeployHttpRequest(
|
|
|
118
147
|
): Promise<void> {
|
|
119
148
|
const data = msg.data as DeployHttpRequestData;
|
|
120
149
|
|
|
121
|
-
|
|
150
|
+
const validationError = validateDeployRequest(data);
|
|
151
|
+
if (validationError) {
|
|
122
152
|
sendDeployHttpResponse(ctx, ws, {
|
|
123
153
|
requestId: data?.requestId || 'unknown',
|
|
124
|
-
status:
|
|
125
|
-
headers: {},
|
|
126
|
-
body: 'Bad Request: missing required fields (requestId, method, url, port)',
|
|
127
|
-
});
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Reject headers with null bytes or CRLF injection
|
|
132
|
-
if (data.headers && containsHeaderInjection(data.headers)) {
|
|
133
|
-
sendDeployHttpResponse(ctx, ws, {
|
|
134
|
-
requestId: data.requestId,
|
|
135
|
-
status: 400,
|
|
136
|
-
headers: {},
|
|
137
|
-
body: 'Bad Request: headers contain null bytes or CRLF injection',
|
|
138
|
-
});
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Enforce header size limit
|
|
143
|
-
if (data.headers && calculateHeaderSize(data.headers) > MAX_HEADER_SIZE_BYTES) {
|
|
144
|
-
sendDeployHttpResponse(ctx, ws, {
|
|
145
|
-
requestId: data.requestId,
|
|
146
|
-
status: 431,
|
|
154
|
+
status: validationError.status,
|
|
147
155
|
headers: {},
|
|
148
|
-
body:
|
|
156
|
+
body: validationError.body,
|
|
149
157
|
});
|
|
150
158
|
return;
|
|
151
159
|
}
|
|
@@ -201,18 +209,7 @@ export async function handleDeployHttpRequest(
|
|
|
201
209
|
body: bodyBuffer.toString('utf-8'),
|
|
202
210
|
});
|
|
203
211
|
} catch (error: unknown) {
|
|
204
|
-
|
|
205
|
-
let body = 'Bad Gateway';
|
|
206
|
-
|
|
207
|
-
if (error instanceof Error) {
|
|
208
|
-
if (error.name === 'AbortError') {
|
|
209
|
-
status = 504;
|
|
210
|
-
body = 'Gateway Timeout';
|
|
211
|
-
} else if (isConnectionRefused(error)) {
|
|
212
|
-
status = 502;
|
|
213
|
-
body = 'Bad Gateway: target server is not running';
|
|
214
|
-
}
|
|
215
|
-
}
|
|
212
|
+
const { status, body } = classifyFetchError(error);
|
|
216
213
|
|
|
217
214
|
sendDeployHttpResponse(ctx, ws, {
|
|
218
215
|
requestId: data.requestId,
|