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.
Files changed (70) hide show
  1. package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
  2. package/dist/server/cli/headless/claude-invoker-process.js +5 -1
  3. package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
  4. package/dist/server/cli/headless/haiku-assessments.d.ts.map +1 -1
  5. package/dist/server/cli/headless/haiku-assessments.js +20 -28
  6. package/dist/server/cli/headless/haiku-assessments.js.map +1 -1
  7. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  8. package/dist/server/cli/headless/stall-assessor.js +17 -3
  9. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  10. package/dist/server/cli/prompt-builders.d.ts.map +1 -1
  11. package/dist/server/cli/prompt-builders.js +35 -19
  12. package/dist/server/cli/prompt-builders.js.map +1 -1
  13. package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
  14. package/dist/server/mcp/bouncer-haiku.js +5 -30
  15. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  16. package/dist/server/mcp/security-analysis.d.ts.map +1 -1
  17. package/dist/server/mcp/security-analysis.js +19 -11
  18. package/dist/server/mcp/security-analysis.js.map +1 -1
  19. package/dist/server/services/deploy/headless-session-handler.d.ts.map +1 -1
  20. package/dist/server/services/deploy/headless-session-handler.js +61 -69
  21. package/dist/server/services/deploy/headless-session-handler.js.map +1 -1
  22. package/dist/server/services/pathUtils.d.ts.map +1 -1
  23. package/dist/server/services/pathUtils.js +46 -38
  24. package/dist/server/services/pathUtils.js.map +1 -1
  25. package/dist/server/services/plan/agent-loader.d.ts +20 -4
  26. package/dist/server/services/plan/agent-loader.d.ts.map +1 -1
  27. package/dist/server/services/plan/agent-loader.js +85 -16
  28. package/dist/server/services/plan/agent-loader.js.map +1 -1
  29. package/dist/server/services/plan/issue-retry.d.ts +0 -8
  30. package/dist/server/services/plan/issue-retry.d.ts.map +1 -1
  31. package/dist/server/services/plan/issue-retry.js +72 -63
  32. package/dist/server/services/plan/issue-retry.js.map +1 -1
  33. package/dist/server/services/plan/review-gate.js +16 -88
  34. package/dist/server/services/plan/review-gate.js.map +1 -1
  35. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -1
  36. package/dist/server/services/websocket/git-handlers.js +6 -19
  37. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  38. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -1
  39. package/dist/server/services/websocket/git-pr-handlers.js +5 -21
  40. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -1
  41. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts.map +1 -1
  42. package/dist/server/services/websocket/handlers/deploy-handlers.js +28 -33
  43. package/dist/server/services/websocket/handlers/deploy-handlers.js.map +1 -1
  44. package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
  45. package/dist/server/services/websocket/plan-board-handlers.js +31 -25
  46. package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
  47. package/dist/server/services/websocket/quality-fix-agent.d.ts.map +1 -1
  48. package/dist/server/services/websocket/quality-fix-agent.js +11 -18
  49. package/dist/server/services/websocket/quality-fix-agent.js.map +1 -1
  50. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  51. package/dist/server/services/websocket/quality-review-agent.js +13 -150
  52. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  53. package/package.json +1 -1
  54. package/server/cli/headless/claude-invoker-process.ts +5 -1
  55. package/server/cli/headless/haiku-assessments.ts +21 -28
  56. package/server/cli/headless/stall-assessor.ts +17 -3
  57. package/server/cli/prompt-builders.ts +34 -23
  58. package/server/mcp/bouncer-haiku.ts +5 -30
  59. package/server/mcp/security-analysis.ts +19 -12
  60. package/server/services/deploy/headless-session-handler.ts +75 -76
  61. package/server/services/pathUtils.ts +55 -42
  62. package/server/services/plan/agent-loader.ts +88 -15
  63. package/server/services/plan/issue-retry.ts +93 -68
  64. package/server/services/plan/review-gate.ts +13 -89
  65. package/server/services/websocket/git-handlers.ts +6 -18
  66. package/server/services/websocket/git-pr-handlers.ts +5 -20
  67. package/server/services/websocket/handlers/deploy-handlers.ts +34 -37
  68. package/server/services/websocket/plan-board-handlers.ts +36 -21
  69. package/server/services/websocket/quality-fix-agent.ts +10 -17
  70. 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. System default: cli/server/services/plan/agents/{agentName}.md
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 - The agent file name without extension (e.g., "review-code")
41
- * @param variables - Key-value map for {{variable}} substitution
42
- * @param boardDir - Optional board directory for board-level overrides
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 boardAgentPath = join(boardDir, 'agents', fileName);
55
- if (existsSync(boardAgentPath)) {
56
- try {
57
- const raw = readFileSync(boardAgentPath, 'utf-8');
58
- return interpolate(stripFrontmatter(raw), variables);
59
- } catch { /* fall through to system default */ }
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
- // 2. System default
64
- const systemPath = join(SYSTEM_AGENTS_DIR, fileName);
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
- const raw = readFileSync(systemPath, 'utf-8');
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
- // Check abort before starting a new attempt
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
- // Clean up abort listener
142
- config.abortSignal?.removeEventListener('abort', abortHandler);
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
- // Evaluate retry strategies in priority order
158
- if (tryToolTimeoutRetry(state, result, config)) continue;
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
- return loadAgentPrompt('review-custom', {
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) ?? buildCustomFallback(issue, contextSection, criteriaStr, reviewCriteria, readInstruction);
216
+ }, boardDir, workingDir);
217
+ if (result) return result;
216
218
  }
217
219
 
218
220
  if (isCodeTask) {
219
- return loadAgentPrompt('review-code', {
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) ?? buildCodeFallback(issue, filesModified, criteriaStr, outputPath);
227
+ }, boardDir, workingDir);
228
+ if (result) return result;
226
229
  }
227
230
 
228
- return loadAgentPrompt('review-quality', {
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) ?? buildQualityFallback(issue, outputPath, issueSpecPath, criteriaStr);
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
- ## Acceptance Criteria
272
- ${criteria}
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 prompt = `You are generating a git commit message for the following staged changes.
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
- RECENT COMMIT MESSAGES (for style reference):
201
- ${logResult.stdout.trim() || 'No recent commits'}
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 prompt = `You are generating a pull request title and description for the following changes.
276
+ const filesChanged = statResult.exitCode === 0 ? statResult.stdout.trim() : '';
277
+ const diff = truncateDiff(diffResult.exitCode === 0 ? diffResult.stdout : '');
276
278
 
277
- COMMITS (${baseBranch}..HEAD):
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
- if (!data?.requestId || !data?.method || !data?.url || !data?.port) {
150
+ const validationError = validateDeployRequest(data);
151
+ if (validationError) {
122
152
  sendDeployHttpResponse(ctx, ws, {
123
153
  requestId: data?.requestId || 'unknown',
124
- status: 400,
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: 'Request Header Fields Too Large: total headers exceed 16KB',
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
- let status = 502;
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,