mstro-app 0.4.29 → 0.4.33
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/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/improvisation-retry.d.ts.map +1 -1
- package/dist/server/cli/improvisation-retry.js +18 -1
- package/dist/server/cli/improvisation-retry.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +5 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +41 -1
- package/dist/server/cli/improvisation-session-manager.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/files.d.ts.map +1 -1
- package/dist/server/services/files.js +6 -2
- package/dist/server/services/files.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 +69 -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/file-explorer-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.js +23 -2
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-handlers.js +21 -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/handler.d.ts +2 -0
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +36 -18
- package/dist/server/services/websocket/handler.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/dist/server/services/websocket/session-history.d.ts.map +1 -1
- package/dist/server/services/websocket/session-history.js +10 -8
- package/dist/server/services/websocket/session-history.js.map +1 -1
- package/dist/server/services/websocket/skill-handlers.d.ts +4 -0
- package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/skill-handlers.js +93 -0
- package/dist/server/services/websocket/skill-handlers.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +8 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/utils/paths.d.ts +4 -0
- package/dist/server/utils/paths.d.ts.map +1 -1
- package/dist/server/utils/paths.js +18 -1
- package/dist/server/utils/paths.js.map +1 -1
- package/package.json +1 -1
- package/server/cli/headless/haiku-assessments.ts +21 -28
- package/server/cli/headless/stall-assessor.ts +17 -3
- package/server/cli/improvisation-retry.ts +19 -1
- package/server/cli/improvisation-session-manager.ts +44 -1
- 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/files.ts +7 -2
- package/server/services/pathUtils.ts +55 -42
- package/server/services/plan/agent-loader.ts +73 -15
- package/server/services/plan/issue-retry.ts +93 -68
- package/server/services/plan/review-gate.ts +13 -89
- package/server/services/websocket/file-explorer-handlers.ts +23 -2
- package/server/services/websocket/git-handlers.ts +23 -18
- package/server/services/websocket/git-pr-handlers.ts +5 -20
- package/server/services/websocket/handler.ts +35 -16
- 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
- package/server/services/websocket/session-history.ts +10 -8
- package/server/services/websocket/skill-handlers.ts +90 -0
- package/server/services/websocket/types.ts +13 -2
- package/server/utils/paths.ts +17 -1
|
@@ -11,6 +11,54 @@
|
|
|
11
11
|
import { existsSync, lstatSync, realpathSync } from 'node:fs';
|
|
12
12
|
import { dirname, isAbsolute, normalize, relative, resolve } from 'node:path';
|
|
13
13
|
|
|
14
|
+
/** Append a trailing separator to a directory path if not already present. */
|
|
15
|
+
function ensureTrailingSep(dir: string): string {
|
|
16
|
+
return dir.endsWith('/') ? dir : `${dir}/`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Resolve symlinks for an existing path. Returns the real path if it's a symlink. */
|
|
20
|
+
function resolveExistingSymlink(resolvedPath: string): string {
|
|
21
|
+
const stat = lstatSync(resolvedPath);
|
|
22
|
+
if (stat.isSymbolicLink()) {
|
|
23
|
+
return realpathSync(resolvedPath);
|
|
24
|
+
}
|
|
25
|
+
return resolvedPath;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validate that the parent directory of a non-existent path hasn't escaped
|
|
30
|
+
* the working directory via symlink. Returns an error result or null if valid.
|
|
31
|
+
*/
|
|
32
|
+
function validateParentSymlink(
|
|
33
|
+
resolvedPath: string,
|
|
34
|
+
normalizedWorkingDir: string,
|
|
35
|
+
targetPath: string,
|
|
36
|
+
): PathValidationResult | null {
|
|
37
|
+
const parentDir = dirname(resolvedPath);
|
|
38
|
+
if (!existsSync(parentDir)) return null;
|
|
39
|
+
|
|
40
|
+
const realParent = realpathSync(parentDir);
|
|
41
|
+
const parentWithSep = ensureTrailingSep(normalizedWorkingDir);
|
|
42
|
+
if (realParent !== normalizedWorkingDir && !realParent.startsWith(parentWithSep)) {
|
|
43
|
+
console.error(
|
|
44
|
+
`[PathUtils] SECURITY: Symlink traversal in parent directory blocked. ` +
|
|
45
|
+
`Target: "${targetPath}", RealParent: "${realParent}", WorkingDir: "${normalizedWorkingDir}"`
|
|
46
|
+
);
|
|
47
|
+
return {
|
|
48
|
+
valid: false,
|
|
49
|
+
resolvedPath: '',
|
|
50
|
+
error: 'Access denied: parent directory resolves outside working directory'
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Check whether a resolved path is within the working directory boundary. */
|
|
57
|
+
function isPathWithinDir(resolvedPath: string, normalizedWorkingDir: string): boolean {
|
|
58
|
+
return resolvedPath === normalizedWorkingDir ||
|
|
59
|
+
resolvedPath.startsWith(ensureTrailingSep(normalizedWorkingDir));
|
|
60
|
+
}
|
|
61
|
+
|
|
14
62
|
export interface PathValidationResult {
|
|
15
63
|
valid: boolean;
|
|
16
64
|
resolvedPath: string;
|
|
@@ -34,12 +82,9 @@ export function validatePathWithinWorkingDir(
|
|
|
34
82
|
const normalizedWorkingDir = resolve(workingDir);
|
|
35
83
|
|
|
36
84
|
// Resolve the target path relative to working directory
|
|
37
|
-
let resolvedPath
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
} else {
|
|
41
|
-
resolvedPath = resolve(normalizedWorkingDir, targetPath);
|
|
42
|
-
}
|
|
85
|
+
let resolvedPath = isAbsolute(targetPath)
|
|
86
|
+
? resolve(targetPath)
|
|
87
|
+
: resolve(normalizedWorkingDir, targetPath);
|
|
43
88
|
|
|
44
89
|
// Normalize to remove any .. or . segments
|
|
45
90
|
resolvedPath = normalize(resolvedPath);
|
|
@@ -47,47 +92,15 @@ export function validatePathWithinWorkingDir(
|
|
|
47
92
|
// Resolve symlinks to prevent symlink-based path traversal.
|
|
48
93
|
// A symlink at /project/link -> /etc/passwd would pass the string
|
|
49
94
|
// check below but actually read outside the working directory.
|
|
50
|
-
// For existing paths: resolve the full path via realpath.
|
|
51
|
-
// For new paths (create operations): resolve the parent directory.
|
|
52
95
|
if (existsSync(resolvedPath)) {
|
|
53
|
-
|
|
54
|
-
const stat = lstatSync(resolvedPath);
|
|
55
|
-
if (stat.isSymbolicLink()) {
|
|
56
|
-
resolvedPath = realpathSync(resolvedPath);
|
|
57
|
-
}
|
|
96
|
+
resolvedPath = resolveExistingSymlink(resolvedPath);
|
|
58
97
|
} else {
|
|
59
98
|
// Path doesn't exist yet (create operation) — validate the parent
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
const realParent = realpathSync(parentDir);
|
|
63
|
-
const parentWithSep = normalizedWorkingDir.endsWith('/')
|
|
64
|
-
? normalizedWorkingDir
|
|
65
|
-
: `${normalizedWorkingDir}/`;
|
|
66
|
-
if (realParent !== normalizedWorkingDir && !realParent.startsWith(parentWithSep)) {
|
|
67
|
-
console.error(
|
|
68
|
-
`[PathUtils] SECURITY: Symlink traversal in parent directory blocked. ` +
|
|
69
|
-
`Target: "${targetPath}", RealParent: "${realParent}", WorkingDir: "${normalizedWorkingDir}"`
|
|
70
|
-
);
|
|
71
|
-
return {
|
|
72
|
-
valid: false,
|
|
73
|
-
resolvedPath: '',
|
|
74
|
-
error: 'Access denied: parent directory resolves outside working directory'
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
}
|
|
99
|
+
const parentError = validateParentSymlink(resolvedPath, normalizedWorkingDir, targetPath);
|
|
100
|
+
if (parentError) return parentError;
|
|
78
101
|
}
|
|
79
102
|
|
|
80
|
-
|
|
81
|
-
// Add trailing separator to prevent partial matches (e.g., /home/user vs /home/username)
|
|
82
|
-
const workingDirWithSep = normalizedWorkingDir.endsWith('/')
|
|
83
|
-
? normalizedWorkingDir
|
|
84
|
-
: `${normalizedWorkingDir}/`;
|
|
85
|
-
|
|
86
|
-
const isWithinWorkingDir =
|
|
87
|
-
resolvedPath === normalizedWorkingDir ||
|
|
88
|
-
resolvedPath.startsWith(workingDirWithSep);
|
|
89
|
-
|
|
90
|
-
if (!isWithinWorkingDir) {
|
|
103
|
+
if (!isPathWithinDir(resolvedPath, normalizedWorkingDir)) {
|
|
91
104
|
// Log security violation for monitoring
|
|
92
105
|
console.error(
|
|
93
106
|
`[PathUtils] SECURITY: Path traversal attempt blocked. ` +
|
|
@@ -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).
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
import { existsSync, readFileSync } from 'node:fs';
|
|
16
17
|
import { dirname, join } from 'node:path';
|
|
17
18
|
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { findSkillsDir } from '../../utils/paths.js';
|
|
18
20
|
|
|
19
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
22
|
const SYSTEM_AGENTS_DIR = join(__dirname, 'agents');
|
|
@@ -34,40 +36,96 @@ function interpolate(template: string, variables: Record<string, string>): strin
|
|
|
34
36
|
});
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
/** Try to load and interpolate a prompt file. Returns null on failure. */
|
|
40
|
+
function tryLoadFile(filePath: string, variables: Record<string, string>): string | null {
|
|
41
|
+
if (!existsSync(filePath)) return null;
|
|
42
|
+
try {
|
|
43
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
44
|
+
return interpolate(stripFrontmatter(raw), variables);
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
/**
|
|
38
51
|
* Load an agent prompt by name with layered resolution.
|
|
39
52
|
*
|
|
40
|
-
* @param agentName
|
|
41
|
-
* @param variables
|
|
42
|
-
* @param boardDir
|
|
53
|
+
* @param agentName - The agent file name without extension (e.g., "review-code")
|
|
54
|
+
* @param variables - Key-value map for {{variable}} substitution
|
|
55
|
+
* @param boardDir - Optional board directory for board-level overrides
|
|
56
|
+
* @param workingDir - Optional working directory for project-level Skill resolution
|
|
43
57
|
* @returns The interpolated prompt string, or null if no agent file found
|
|
44
58
|
*/
|
|
45
59
|
export function loadAgentPrompt(
|
|
46
60
|
agentName: string,
|
|
47
61
|
variables: Record<string, string>,
|
|
48
62
|
boardDir?: string | null,
|
|
63
|
+
workingDir?: string | null,
|
|
49
64
|
): string | null {
|
|
50
65
|
const fileName = `${agentName}.md`;
|
|
51
66
|
|
|
52
67
|
// 1. Board-level override
|
|
53
68
|
if (boardDir) {
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
69
|
+
const result = tryLoadFile(join(boardDir, 'agents', fileName), variables);
|
|
70
|
+
if (result) return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 2. Project Skill: {workingDir}/.claude/skills/{agentName}/SKILL.md
|
|
74
|
+
if (workingDir) {
|
|
75
|
+
const skillsDir = findSkillsDir(workingDir);
|
|
76
|
+
if (skillsDir) {
|
|
77
|
+
const result = tryLoadFile(join(skillsDir, agentName, 'SKILL.md'), variables);
|
|
78
|
+
if (result) return result;
|
|
60
79
|
}
|
|
61
80
|
}
|
|
62
81
|
|
|
63
|
-
//
|
|
64
|
-
|
|
82
|
+
// 3. System default
|
|
83
|
+
return tryLoadFile(join(SYSTEM_AGENTS_DIR, fileName), variables);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Load a Skill template body by name, stripping frontmatter.
|
|
88
|
+
* Looks in {workingDir}/.claude/skills/{skillName}/SKILL.md first,
|
|
89
|
+
* then falls back to the system agents directory.
|
|
90
|
+
*
|
|
91
|
+
* @param skillName - The skill directory name (e.g., "code-review")
|
|
92
|
+
* @param workingDir - Working directory for project-level Skill resolution
|
|
93
|
+
* @returns Raw template body (no frontmatter), or null if not found
|
|
94
|
+
*/
|
|
95
|
+
export function loadSkillTemplate(skillName: string, workingDir?: string): string | null {
|
|
96
|
+
if (workingDir) {
|
|
97
|
+
const skillsDir = findSkillsDir(workingDir);
|
|
98
|
+
if (skillsDir) {
|
|
99
|
+
const path = join(skillsDir, skillName, 'SKILL.md');
|
|
100
|
+
if (existsSync(path)) {
|
|
101
|
+
try {
|
|
102
|
+
return stripFrontmatter(readFileSync(path, 'utf-8'));
|
|
103
|
+
} catch { /* fall through */ }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fallback: system agents directory
|
|
109
|
+
const systemPath = join(SYSTEM_AGENTS_DIR, `${skillName}.md`);
|
|
65
110
|
if (existsSync(systemPath)) {
|
|
66
111
|
try {
|
|
67
|
-
|
|
68
|
-
return interpolate(stripFrontmatter(raw), variables);
|
|
112
|
+
return stripFrontmatter(readFileSync(systemPath, 'utf-8'));
|
|
69
113
|
} catch { /* return null */ }
|
|
70
114
|
}
|
|
71
115
|
|
|
72
116
|
return null;
|
|
73
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Load a Skill template and interpolate variables.
|
|
121
|
+
* Convenience wrapper combining loadSkillTemplate + interpolation.
|
|
122
|
+
*/
|
|
123
|
+
export function loadSkillPrompt(
|
|
124
|
+
skillName: string,
|
|
125
|
+
variables: Record<string, string>,
|
|
126
|
+
workingDir?: string,
|
|
127
|
+
): string | null {
|
|
128
|
+
const template = loadSkillTemplate(skillName, workingDir);
|
|
129
|
+
if (!template) return null;
|
|
130
|
+
return interpolate(template, variables);
|
|
131
|
+
}
|
|
@@ -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
|
-
}
|
|
@@ -111,9 +111,30 @@ export function handleFileExplorerMessage(ctx: HandlerContext, ws: WSContext, ms
|
|
|
111
111
|
handleRenameFile(ctx, ws, msg, tabId, workingDir);
|
|
112
112
|
},
|
|
113
113
|
notifyFileOpened: () => handleNotifyFileOpened(ctx, ws, msg, workingDir),
|
|
114
|
-
searchFileContents: () =>
|
|
114
|
+
searchFileContents: () => {
|
|
115
|
+
if (isSandboxed && msg.data?.query) {
|
|
116
|
+
const searchPath = msg.data.path || msg.data.dirPath;
|
|
117
|
+
if (searchPath) {
|
|
118
|
+
const validation = validatePathWithinWorkingDir(searchPath, workingDir);
|
|
119
|
+
if (!validation.valid) {
|
|
120
|
+
ctx.send(ws, { type: 'contentSearchError', tabId, data: { error: 'Sandboxed: search path outside project directory' } });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
handleSearchFileContents(ctx, ws, msg, tabId, workingDir);
|
|
126
|
+
},
|
|
115
127
|
cancelSearch: () => handleCancelSearch(ctx, tabId),
|
|
116
|
-
findDefinition: () =>
|
|
128
|
+
findDefinition: () => {
|
|
129
|
+
if (isSandboxed && msg.data?.filePath) {
|
|
130
|
+
const validation = validatePathWithinWorkingDir(msg.data.filePath, workingDir);
|
|
131
|
+
if (!validation.valid) {
|
|
132
|
+
ctx.send(ws, { type: 'definitionResult', tabId, data: { definitions: [], symbol: msg.data.symbol || '' } });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
handleFindDefinition(ctx, ws, msg, tabId, workingDir);
|
|
137
|
+
},
|
|
117
138
|
};
|
|
118
139
|
const handler = handlers[msg.type];
|
|
119
140
|
if (!handler) return;
|