@reconcrap/boss-recommend-mcp 0.1.14 → 1.0.0

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/README.md CHANGED
@@ -25,6 +25,7 @@ MCP 工具名:`run_recommend_pipeline`
25
25
  - 学历支持单选与多选语义:如“本科及以上”会展开为 `本科/硕士/博士`;如“大专、本科”只勾选这两项
26
26
  - 执行前会逐项确认筛选参数:学校标签 / 学历 / 性别 / 是否过滤近14天已看
27
27
  - 页面就绪(已登录且在 recommend 页)后,会先提取岗位栏全部岗位并要求用户确认本次岗位;确认后先点击岗位,再执行 search/screen
28
+ - 在真正开始 search/screen 前,会进行最后一轮全参数总确认(岗位 + 全部筛选参数 + criteria + target_count + post_action + max_greet_count)
28
29
  - npm 全局安装后会自动执行 install:生成 skill、导出 MCP 模板,并自动尝试写入已检测到的外部 agent MCP 配置(含 Trae / trae-cn / Cursor / Claude / OpenClaw)
29
30
  - `post_action` 必须在每次完整运行开始时确认一次
30
31
  - `target_count` 会在每次运行开始时询问一次(可留空,不设上限)
@@ -64,6 +65,7 @@ BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS # JSON 数组或系统 path 分隔路径列
64
65
 
65
66
  ```bash
66
67
  node src/cli.js run --instruction "推荐页筛选985男生,近14天没有,有大模型平台经验,符合标准的收藏"
68
+ npx -y @reconcrap/boss-recommend-mcp@latest run --instruction "推荐页筛选985男生,近14天没有,有大模型平台经验,符合标准的收藏"
67
69
  ```
68
70
 
69
71
  ## 配置
@@ -77,9 +79,9 @@ node src/cli.js run --instruction "推荐页筛选985男生,近14天没有,
77
79
  配置路径优先级:
78
80
 
79
81
  1. `BOSS_RECOMMEND_SCREEN_CONFIG`
80
- 2. `<workspace>/config/screening-config.json`
82
+ 2. `<workspace>/config/screening-config.json`(优先;在受限权限环境推荐使用)
81
83
  3. `<workspace>/boss-recommend-mcp/config/screening-config.json`
82
- 4. `~/.boss-recommend-mcp/screening-config.json`(若可写)
84
+ 4. `~/.boss-recommend-mcp/screening-config.json`(当 workspace 不可写或无 workspace 时回退)
83
85
  5. 兼容旧路径:`$CODEX_HOME/boss-recommend-mcp/screening-config.json`
84
86
 
85
87
  注意:
@@ -127,14 +129,19 @@ node src/cli.js run --instruction-file request.txt --confirmation-file confirmat
127
129
  "confirmation": {
128
130
  "filters_confirmed": true,
129
131
  "school_tag_confirmed": true,
132
+ "school_tag_value": ["985", "211"],
130
133
  "degree_confirmed": true,
134
+ "degree_value": ["本科", "硕士", "博士"],
131
135
  "gender_confirmed": true,
136
+ "gender_value": "女",
132
137
  "recent_not_view_confirmed": true,
138
+ "recent_not_view_value": "近14天没有",
133
139
  "criteria_confirmed": true,
134
140
  "target_count_confirmed": true,
135
141
  "target_count_value": 20,
136
142
  "post_action_confirmed": true,
137
143
  "post_action_value": "greet",
144
+ "final_confirmed": true,
138
145
  "job_confirmed": true,
139
146
  "job_value": "算法工程师(视频/图像模型方向) _ 杭州",
140
147
  "max_greet_count_confirmed": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "0.1.14",
3
+ "version": "1.0.0",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -51,7 +51,11 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
51
51
  - 严禁在未询问用户的情况下自动把 `max_greet_count` 设为 `target_count` 或其他默认值
52
52
  - 岗位(`job`)是否确定
53
53
  - 必须先列出 recommend 页岗位栏里识别到的全部岗位,让用户明确选择
54
+ - 即使前序步骤已提取到 `job` 参数,执行前也必须再次展示岗位列表并让用户二次确认
54
55
  - 用户确认后必须先点击该岗位,再开始 search 和 screen
56
+ - 正式开始 search/screen 前,必须做最后一轮“全参数总确认”
57
+ - 需要向用户复述并确认:岗位、school_tag、degree、gender、recent_not_view、criteria、target_count、post_action、max_greet_count
58
+ - 只有用户明确最终确认后才允许执行
55
59
 
56
60
  `post_action` 的确认是**单次运行级别**的:
57
61
 
@@ -64,17 +68,22 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
64
68
  - Tool name: `run_recommend_pipeline`
65
69
  - Input:
66
70
  - `instruction` (required)
67
- - `confirmation`
68
- - `filters_confirmed`
69
- - `school_tag_confirmed`
70
- - `degree_confirmed`
71
- - `gender_confirmed`
72
- - `recent_not_view_confirmed`
73
- - `criteria_confirmed`
71
+ - `confirmation`
72
+ - `filters_confirmed`
73
+ - `school_tag_confirmed`
74
+ - `school_tag_value`(建议回传最终确认值,避免二轮调用丢失)
75
+ - `degree_confirmed`
76
+ - `degree_value`
77
+ - `gender_confirmed`
78
+ - `gender_value`
79
+ - `recent_not_view_confirmed`
80
+ - `recent_not_view_value`
81
+ - `criteria_confirmed`
74
82
  - `target_count_confirmed`
75
83
  - `target_count_value` (integer, optional)
76
84
  - `post_action_confirmed`
77
85
  - `post_action_value` (`favorite|greet`)
86
+ - `final_confirmed`
78
87
  - `job_confirmed`
79
88
  - `job_value` (string)
80
89
  - `max_greet_count_confirmed`
@@ -98,11 +107,17 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
98
107
  - recommend-screen-cli 负责滚动推荐列表、打开详情、提取完整简历图、调用多模态模型判断,并按单次确认的 `post_action` 执行收藏或打招呼。
99
108
  - 详情页处理完成后必须关闭详情页并确认已关闭。
100
109
 
101
- ## Fallback
102
-
103
- 如果 MCP 不可用,改用:
104
-
105
- `boss-recommend-mcp run --instruction "..." [--confirmation-json '{...}'] [--overrides-json '{...}']`
110
+ ## Fallback
111
+
112
+ 如果 MCP 不可用,改用:
113
+
114
+ `npx -y @reconcrap/boss-recommend-mcp@latest run --instruction "..." [--confirmation-json '{...}'] [--overrides-json '{...}']`
115
+
116
+ 禁止错误回退:
117
+
118
+ - 不能把 recommend 请求回退到 `boss-recruit-mcp` / `run_recruit_pipeline`
119
+ - 不能执行 `boss-recruit-mcp doctor` 作为 recommend 流程的环境检查
120
+ - 若检测到当前环境只有 recruit MCP,应先修复 recommend MCP 配置,再继续
106
121
 
107
122
  CLI fallback 的状态机与 MCP 保持一致:
108
123
 
@@ -135,6 +150,8 @@ CLI fallback 的状态机与 MCP 保持一致:
135
150
  当工具返回 `status=FAILED` 且 `error.code=PIPELINE_PREFLIGHT_FAILED` 时:
136
151
 
137
152
  1. 若 `diagnostics.checks` 中 `screen_config` 失败,优先引导用户填写 `screening-config.json` 的 `baseUrl/apiKey/model`(必须让用户提供真实值,不可保留模板值)。
153
+ - 禁止 agent 自行代填或猜测示例值(如 `test-key` / `mock-key` / `https://example.com` / `gpt-4` 占位等)
154
+ - 必须逐项向用户确认 `baseUrl`、`apiKey`、`model` 后再写入
138
155
  2. 优先查看 `diagnostics.auto_repair`,若有自动修复动作则先基于其结果继续执行或给出最小化补救提示。
139
156
  3. 若自动修复后仍失败,再读取 `diagnostics.recovery.agent_prompt`,直接把这段提示词交给 AI agent 执行环境修复。
140
157
  4. 若 `diagnostics.recovery.agent_prompt` 不存在,使用下面的兜底提示词(严格顺序,不可跳步):
package/src/adapters.js CHANGED
@@ -126,6 +126,34 @@ function resolveScreenConfigCandidates(workspaceRoot) {
126
126
  ].filter(Boolean);
127
127
  }
128
128
 
129
+ function canWriteDirectory(targetDir) {
130
+ try {
131
+ ensureDir(targetDir);
132
+ fs.accessSync(targetDir, fs.constants.W_OK);
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ function resolveWritableScreenConfigPath(workspaceRoot) {
140
+ const candidateMap = buildScreenConfigCandidateMap(workspaceRoot);
141
+ const workspacePreferred = candidateMap.workspace_paths?.[0] || null;
142
+ if (candidateMap.env_path) {
143
+ return candidateMap.env_path;
144
+ }
145
+ if (workspacePreferred && canWriteDirectory(path.dirname(workspacePreferred))) {
146
+ return workspacePreferred;
147
+ }
148
+ if (candidateMap.user_path && canWriteDirectory(path.dirname(candidateMap.user_path))) {
149
+ return candidateMap.user_path;
150
+ }
151
+ if (workspacePreferred) {
152
+ return workspacePreferred;
153
+ }
154
+ return candidateMap.user_path || candidateMap.legacy_path;
155
+ }
156
+
129
157
  function resolveScreenConfigPath(workspaceRoot) {
130
158
  const candidateMap = buildScreenConfigCandidateMap(workspaceRoot);
131
159
  if (candidateMap.env_path) {
@@ -135,9 +163,9 @@ function resolveScreenConfigPath(workspaceRoot) {
135
163
  if (existingWorkspacePath) {
136
164
  return existingWorkspacePath;
137
165
  }
138
- // 默认固定写入/读取 Agent 无关路径,避免落到 npx 临时目录或 .codex 旧路径。
139
- if (candidateMap.user_path) {
140
- return candidateMap.user_path;
166
+ const writablePath = resolveWritableScreenConfigPath(workspaceRoot);
167
+ if (writablePath) {
168
+ return writablePath;
141
169
  }
142
170
  return candidateMap.legacy_path;
143
171
  }
@@ -151,7 +179,7 @@ export function getScreenConfigResolution(workspaceRoot) {
151
179
  candidate_paths,
152
180
  workspace_root: path.resolve(String(workspaceRoot || process.cwd())),
153
181
  workspace_ephemeral: isEphemeralNpxWorkspaceRoot(workspaceRoot),
154
- writable_path: candidateMap.user_path,
182
+ writable_path: resolveWritableScreenConfigPath(workspaceRoot),
155
183
  legacy_path: candidateMap.legacy_path
156
184
  };
157
185
  }
@@ -860,6 +888,19 @@ export async function inspectBossRecommendPageState(port, options = {}) {
860
888
  (tab) => typeof tab?.url === "string" && tab.url.includes("/web/chat/recommend")
861
889
  );
862
890
  if (exactTab) {
891
+ if (isBossLoginTab(exactTab)) {
892
+ return buildBossPageState({
893
+ ok: false,
894
+ state: "LOGIN_REQUIRED",
895
+ path: exactTab.url || bossLoginUrl,
896
+ current_url: exactTab.url || bossLoginUrl,
897
+ title: exactTab.title || null,
898
+ requires_login: true,
899
+ expected_url: expectedUrl,
900
+ login_url: bossLoginUrl,
901
+ message: "当前标签页虽在 recommend 路径,但检测到登录态页面特征,请先完成 Boss 登录。"
902
+ });
903
+ }
863
904
  return buildBossPageState({
864
905
  ok: true,
865
906
  state: "RECOMMEND_READY",
package/src/cli.js CHANGED
@@ -27,6 +27,8 @@ const supportedMcpClients = ["generic", "cursor", "trae", "claudecode", "opencla
27
27
  const defaultMcpServerName = "boss-recommend";
28
28
  const defaultMcpCommand = "npx";
29
29
  const defaultMcpArgs = ["-y", "@reconcrap/boss-recommend-mcp@latest", "start"];
30
+ const recommendMcpPackageName = "@reconcrap/boss-recommend-mcp";
31
+ const recommendMcpBinaryName = "boss-recommend-mcp";
30
32
  const autoSyncSkipCommands = new Set(["install", "install-skill", "where", "help", "--help", "-h"]);
31
33
  const externalMcpTargetsEnv = "BOSS_RECOMMEND_MCP_CONFIG_TARGETS";
32
34
  const externalSkillDirsEnv = "BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS";
@@ -83,7 +85,9 @@ function dedupePaths(items) {
83
85
  const result = [];
84
86
  const seen = new Set();
85
87
  for (const item of items || []) {
86
- const resolved = path.resolve(String(item || ""));
88
+ const raw = String(item ?? "").trim();
89
+ if (!raw) continue;
90
+ const resolved = path.resolve(raw);
87
91
  if (!resolved || seen.has(resolved)) continue;
88
92
  seen.add(resolved);
89
93
  result.push(resolved);
@@ -91,6 +95,32 @@ function dedupePaths(items) {
91
95
  return result;
92
96
  }
93
97
 
98
+ function dedupeLower(values = []) {
99
+ const seen = new Set();
100
+ const result = [];
101
+ for (const value of values) {
102
+ const normalized = String(value || "").trim();
103
+ if (!normalized) continue;
104
+ const key = normalized.toLowerCase();
105
+ if (seen.has(key)) continue;
106
+ seen.add(key);
107
+ result.push(normalized);
108
+ }
109
+ return result;
110
+ }
111
+
112
+ function discoverAppDataDirsByPattern(baseDir, pattern) {
113
+ try {
114
+ if (!pathExists(baseDir)) return [];
115
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
116
+ return entries
117
+ .filter((entry) => entry.isDirectory() && pattern.test(entry.name))
118
+ .map((entry) => entry.name);
119
+ } catch {
120
+ return [];
121
+ }
122
+ }
123
+
94
124
  function getDesktopDir() {
95
125
  return path.join(os.homedir(), "Desktop");
96
126
  }
@@ -149,9 +179,29 @@ function parsePositivePort(raw) {
149
179
  return Number.isFinite(port) && port > 0 ? port : null;
150
180
  }
151
181
 
182
+ function isEphemeralWorkspaceRoot(rootPath) {
183
+ const normalized = path.resolve(String(rootPath || ""))
184
+ .replace(/\\/g, "/")
185
+ .toLowerCase();
186
+ return (
187
+ normalized.includes("/appdata/local/npm-cache/_npx/")
188
+ || normalized.includes("/node_modules/@reconcrap/boss-recommend-mcp")
189
+ );
190
+ }
191
+
152
192
  function getWorkspaceRoot(options) {
153
- const raw = options["workspace-root"] || process.env.BOSS_WORKSPACE_ROOT || process.cwd();
154
- return path.resolve(String(raw));
193
+ const fromOption = String(options["workspace-root"] || "").trim();
194
+ if (fromOption) return path.resolve(fromOption);
195
+
196
+ const fromEnv = String(process.env.BOSS_WORKSPACE_ROOT || "").trim();
197
+ if (fromEnv) return path.resolve(fromEnv);
198
+
199
+ const cwd = path.resolve(process.cwd());
200
+ const initCwd = String(process.env.INIT_CWD || "").trim();
201
+ if (isEphemeralWorkspaceRoot(cwd) && initCwd) {
202
+ return path.resolve(initCwd);
203
+ }
204
+ return cwd;
155
205
  }
156
206
 
157
207
  function readTextFile(filePath, label) {
@@ -306,10 +356,19 @@ function parseAgentTargets(rawValue) {
306
356
  function getKnownExternalMcpConfigPathsByAgent() {
307
357
  const home = os.homedir();
308
358
  const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
359
+ const traeDirNames = dedupeLower([
360
+ "Trae",
361
+ "Trae CN",
362
+ "TraeCN",
363
+ "trae-cn",
364
+ "trae_cn",
365
+ ...discoverAppDataDirsByPattern(appData, /^trae(?:[\s\-_]?cn)?$/i)
366
+ ]);
367
+ const traeConfigPaths = traeDirNames.map((dir) => path.join(appData, dir, "User", "mcp.json"));
309
368
  return {
310
369
  cursor: [path.join(appData, "Cursor", "User", "mcp.json"), path.join(home, ".cursor", "mcp.json")],
311
- trae: [path.join(appData, "Trae", "User", "mcp.json"), path.join(home, ".trae", "mcp.json")],
312
- "trae-cn": [path.join(appData, "Trae CN", "User", "mcp.json"), path.join(home, ".trae-cn", "mcp.json")],
370
+ trae: [...traeConfigPaths, path.join(home, ".trae", "mcp.json"), path.join(home, ".trae-cn", "mcp.json")],
371
+ "trae-cn": [...traeConfigPaths, path.join(home, ".trae-cn", "mcp.json"), path.join(home, ".trae", "mcp.json")],
313
372
  claude: [path.join(home, ".claude", "mcp.json")],
314
373
  openclaw: [path.join(home, ".openclaw", "mcp.json")]
315
374
  };
@@ -383,15 +442,76 @@ function installExternalMcpConfigs(options = {}) {
383
442
  function getKnownExternalSkillBaseDirsByAgent() {
384
443
  const home = os.homedir();
385
444
  const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
445
+ const traeDirNames = dedupeLower([
446
+ "Trae",
447
+ "Trae CN",
448
+ "TraeCN",
449
+ "trae-cn",
450
+ "trae_cn",
451
+ ...discoverAppDataDirsByPattern(appData, /^trae(?:[\s\-_]?cn)?$/i)
452
+ ]);
453
+ const traeSkillDirs = traeDirNames.map((dir) => path.join(appData, dir, "User", "skills"));
386
454
  return {
387
455
  cursor: [path.join(home, ".cursor", "skills"), path.join(appData, "Cursor", "User", "skills")],
388
- trae: [path.join(home, ".trae", "skills"), path.join(appData, "Trae", "User", "skills")],
389
- "trae-cn": [path.join(home, ".trae-cn", "skills"), path.join(appData, "Trae CN", "User", "skills")],
456
+ trae: [path.join(home, ".trae", "skills"), path.join(home, ".trae-cn", "skills"), ...traeSkillDirs],
457
+ "trae-cn": [path.join(home, ".trae-cn", "skills"), path.join(home, ".trae", "skills"), ...traeSkillDirs],
390
458
  claude: [path.join(home, ".claude", "skills")],
391
459
  openclaw: [path.join(home, ".openclaw", "skills"), path.join(appData, "OpenClaw", "User", "skills")]
392
460
  };
393
461
  }
394
462
 
463
+ function isRecommendMcpLaunchConfig(launchConfig) {
464
+ if (!launchConfig || typeof launchConfig !== "object") return false;
465
+ const command = String(launchConfig.command || "").toLowerCase();
466
+ const args = Array.isArray(launchConfig.args) ? launchConfig.args : [];
467
+ const joined = `${command} ${args.map((item) => String(item || "")).join(" ")}`.toLowerCase();
468
+ return (
469
+ joined.includes(recommendMcpPackageName.toLowerCase())
470
+ || joined.includes(`${recommendMcpBinaryName} start`)
471
+ || (command.endsWith(recommendMcpBinaryName) && args.includes("start"))
472
+ || command === recommendMcpBinaryName
473
+ );
474
+ }
475
+
476
+ function inspectMcpServerEntries(filePath) {
477
+ if (!pathExists(filePath)) {
478
+ return {
479
+ exists: false,
480
+ has_boss_recommend: false,
481
+ has_boss_recruit: false,
482
+ recommend_server_names: [],
483
+ recruit_server_names: []
484
+ };
485
+ }
486
+ const parsed = readJsonObjectFileSafe(filePath);
487
+ const servers = parsed?.mcpServers && typeof parsed.mcpServers === "object" && !Array.isArray(parsed.mcpServers)
488
+ ? parsed.mcpServers
489
+ : {};
490
+ const recommendNames = [];
491
+ const recruitNames = [];
492
+ for (const [name, config] of Object.entries(servers)) {
493
+ const lowerName = String(name || "").toLowerCase();
494
+ if (isRecommendMcpLaunchConfig(config) || lowerName.includes("boss-recommend")) {
495
+ recommendNames.push(name);
496
+ }
497
+ const serialized = JSON.stringify(config || {}).toLowerCase();
498
+ if (
499
+ lowerName.includes("boss-recruit")
500
+ || serialized.includes("@reconcrap/boss-recruit-mcp")
501
+ || serialized.includes("boss-recruit-mcp")
502
+ ) {
503
+ recruitNames.push(name);
504
+ }
505
+ }
506
+ return {
507
+ exists: true,
508
+ has_boss_recommend: recommendNames.length > 0,
509
+ has_boss_recruit: recruitNames.length > 0,
510
+ recommend_server_names: recommendNames,
511
+ recruit_server_names: recruitNames
512
+ };
513
+ }
514
+
395
515
  function resolveExternalSkillBaseDirs(options = {}) {
396
516
  const fromEnv = parsePathListFromEnv(process.env[externalSkillDirsEnv]);
397
517
  const pathMap = getKnownExternalSkillBaseDirsByAgent();
@@ -449,17 +569,52 @@ function installSkill() {
449
569
  return syncSkillAssets({ force: true }).targetDir;
450
570
  }
451
571
 
452
- function ensureUserConfig() {
453
- const targetPath = getUserConfigPath();
454
- ensureDir(path.dirname(targetPath));
455
- if (!fs.existsSync(targetPath)) {
456
- const template = JSON.parse(fs.readFileSync(exampleConfigPath, "utf8"));
457
- template.outputDir = getDesktopDir();
458
- template.debugPort = 9222;
459
- fs.writeFileSync(targetPath, JSON.stringify(template, null, 2), "utf8");
460
- return { path: targetPath, created: true };
572
+ function pathStartsWith(filePath, rootPath) {
573
+ const file = path.resolve(String(filePath || ""));
574
+ const root = path.resolve(String(rootPath || ""));
575
+ if (process.platform === "win32") {
576
+ return file.toLowerCase().startsWith(root.toLowerCase());
461
577
  }
462
- return { path: targetPath, created: false };
578
+ return file.startsWith(root);
579
+ }
580
+
581
+ function resolveCliConfigTarget(options = {}) {
582
+ const workspaceRoot = getWorkspaceRoot(options);
583
+ const resolution = getScreenConfigResolution(workspaceRoot);
584
+ const workspacePreferred = (resolution.candidate_paths || []).find((item) => pathStartsWith(item, workspaceRoot)) || null;
585
+ const configPath = resolution.writable_path || resolution.resolved_path || workspacePreferred || getUserConfigPath();
586
+ return {
587
+ workspaceRoot,
588
+ resolution,
589
+ configPath,
590
+ workspacePreferred
591
+ };
592
+ }
593
+
594
+ function ensureUserConfig(options = {}) {
595
+ const { configPath, workspacePreferred } = resolveCliConfigTarget(options);
596
+ const writeTargets = dedupePaths([configPath, workspacePreferred]).filter(Boolean);
597
+ let lastError = null;
598
+ for (const targetPath of writeTargets) {
599
+ try {
600
+ ensureDir(path.dirname(targetPath));
601
+ if (!fs.existsSync(targetPath)) {
602
+ const template = JSON.parse(fs.readFileSync(exampleConfigPath, "utf8"));
603
+ template.outputDir = getDesktopDir();
604
+ template.debugPort = 9222;
605
+ fs.writeFileSync(targetPath, JSON.stringify(template, null, 2), "utf8");
606
+ return { path: targetPath, created: true };
607
+ }
608
+ const stat = fs.statSync(targetPath);
609
+ if (stat.isFile()) {
610
+ return { path: targetPath, created: false };
611
+ }
612
+ lastError = new Error(`Config target is a directory and cannot be used as file: ${targetPath}`);
613
+ } catch (error) {
614
+ lastError = error;
615
+ }
616
+ }
617
+ throw lastError || new Error("No writable target for screening-config.json");
463
618
  }
464
619
 
465
620
  function readJsonObjectFile(filePath) {
@@ -471,24 +626,57 @@ function readJsonObjectFile(filePath) {
471
626
  return parsed;
472
627
  }
473
628
 
474
- function loadBestExistingUserConfig() {
475
- const primary = getUserConfigPath();
476
- const legacy = getLegacyUserConfigPath();
477
- if (fs.existsSync(primary)) {
478
- return { path: primary, config: readJsonObjectFile(primary) };
629
+ function loadBestExistingUserConfig(options = {}) {
630
+ const { resolution, configPath, workspacePreferred } = resolveCliConfigTarget(options);
631
+ const candidates = dedupePaths([
632
+ ...(resolution.candidate_paths || []),
633
+ configPath,
634
+ workspacePreferred,
635
+ getLegacyUserConfigPath()
636
+ ]).filter(Boolean);
637
+ for (const candidate of candidates) {
638
+ if (fs.existsSync(candidate)) {
639
+ try {
640
+ const stat = fs.statSync(candidate);
641
+ if (!stat.isFile()) {
642
+ continue;
643
+ }
644
+ } catch {
645
+ continue;
646
+ }
647
+ return { path: candidate, config: readJsonObjectFile(candidate) };
648
+ }
479
649
  }
480
- if (fs.existsSync(legacy)) {
481
- return { path: legacy, config: readJsonObjectFile(legacy) };
650
+ return { path: configPath, config: {} };
651
+ }
652
+
653
+ function writeConfigWithFallback(nextConfig, options = {}) {
654
+ const { configPath, workspacePreferred } = resolveCliConfigTarget(options);
655
+ const targets = dedupePaths([configPath, workspacePreferred]).filter(Boolean);
656
+ let lastError = null;
657
+ for (const target of targets) {
658
+ try {
659
+ ensureDir(path.dirname(target));
660
+ if (fs.existsSync(target)) {
661
+ const stat = fs.statSync(target);
662
+ if (!stat.isFile()) {
663
+ lastError = new Error(`Config target is a directory and cannot be used as file: ${target}`);
664
+ continue;
665
+ }
666
+ }
667
+ fs.writeFileSync(target, JSON.stringify(nextConfig, null, 2), "utf8");
668
+ return target;
669
+ } catch (error) {
670
+ lastError = error;
671
+ }
482
672
  }
483
- return { path: primary, config: {} };
673
+ throw lastError || new Error("No writable target for screening-config.json");
484
674
  }
485
675
 
486
- function persistDebugPortSelection(port) {
487
- const { config } = loadBestExistingUserConfig();
488
- const configPath = getUserConfigPath();
676
+ function persistDebugPortSelection(port, options = {}) {
677
+ const { config } = loadBestExistingUserConfig(options);
489
678
  config.debugPort = port;
490
- ensureDir(path.dirname(configPath));
491
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
679
+ const configPath = writeConfigWithFallback(config, options);
492
680
  return { port, configPath };
493
681
  }
494
682
 
@@ -498,7 +686,7 @@ function setDebugPort(options = {}) {
498
686
  throw new Error("Missing required --port <number> for set-port.");
499
687
  }
500
688
  process.env.BOSS_RECOMMEND_CHROME_PORT = String(selected);
501
- return persistDebugPortSelection(selected);
689
+ return persistDebugPortSelection(selected, options);
502
690
  }
503
691
 
504
692
  function setScreeningConfig(options = {}) {
@@ -509,8 +697,7 @@ function setScreeningConfig(options = {}) {
509
697
  throw new Error("Missing required fields: --base-url, --api-key, --model");
510
698
  }
511
699
 
512
- const { config: existing } = loadBestExistingUserConfig();
513
- const configPath = getUserConfigPath();
700
+ const { config: existing } = loadBestExistingUserConfig(options);
514
701
  const nextConfig = {
515
702
  ...existing,
516
703
  baseUrl,
@@ -530,8 +717,7 @@ function setScreeningConfig(options = {}) {
530
717
  if (debugPort) {
531
718
  nextConfig.debugPort = debugPort;
532
719
  }
533
- ensureDir(path.dirname(configPath));
534
- fs.writeFileSync(configPath, JSON.stringify(nextConfig, null, 2), "utf8");
720
+ const configPath = writeConfigWithFallback(nextConfig, options);
535
721
  return { path: configPath, updated: true };
536
722
  }
537
723
 
@@ -650,16 +836,21 @@ function inspectAgentIntegration(agentRaw) {
650
836
 
651
837
  const mcpPathMap = getKnownExternalMcpConfigPathsByAgent();
652
838
  const skillPathMap = getKnownExternalSkillBaseDirsByAgent();
653
- const mcp_paths = dedupePaths(mcpPathMap[agent] || []);
654
- const skill_bases = dedupePaths(skillPathMap[agent] || []);
839
+ const mcp_paths = dedupePaths([
840
+ ...(mcpPathMap[agent] || []),
841
+ ...parsePathListFromEnv(process.env[externalMcpTargetsEnv])
842
+ ]);
843
+ const skill_bases = dedupePaths([
844
+ ...(skillPathMap[agent] || []),
845
+ ...parsePathListFromEnv(process.env[externalSkillDirsEnv])
846
+ ]);
655
847
 
656
848
  const mcp_checks = mcp_paths.map((mcpPath) => {
657
- if (!pathExists(mcpPath)) {
658
- return { path: mcpPath, exists: false, has_boss_recommend: false };
659
- }
660
- const parsed = readJsonObjectFileSafe(mcpPath);
661
- const has = Boolean(parsed?.mcpServers && parsed.mcpServers["boss-recommend"]);
662
- return { path: mcpPath, exists: true, has_boss_recommend: has };
849
+ const detail = inspectMcpServerEntries(mcpPath);
850
+ return {
851
+ path: mcpPath,
852
+ ...detail
853
+ };
663
854
  });
664
855
 
665
856
  const hasRecommendIntent = (content) => /(recommend|推荐页|boss recommend|recommend page)/i.test(content);
@@ -736,6 +927,10 @@ async function printDoctor(options = {}) {
736
927
  if (typeof options.agent === "string" && options.agent.trim()) {
737
928
  agentIntegration = inspectAgentIntegration(options.agent.trim());
738
929
  const agentMcpOk = agentIntegration.mcp_checks.some((item) => item.has_boss_recommend);
930
+ const agentRecruitOnly = (
931
+ !agentMcpOk
932
+ && agentIntegration.mcp_checks.some((item) => item.has_boss_recruit)
933
+ );
739
934
  const agentSkillOk = agentIntegration.skill_checks.some((item) => item.exists);
740
935
  const agentRecruitRouteGuardOk = agentIntegration.skill_checks.every(
741
936
  (item) => !item.recruit_skill_exists || item.recruit_route_guard
@@ -750,7 +945,9 @@ async function printDoctor(options = {}) {
750
945
  path: agentIntegration.mcp_checks.map((item) => item.path).join(" | "),
751
946
  message: agentMcpOk
752
947
  ? "目标 Agent MCP 配置已检测到 boss-recommend。"
753
- : "目标 Agent MCP 配置未检测到 boss-recommend。"
948
+ : agentRecruitOnly
949
+ ? "目标 Agent MCP 配置未检测到 boss-recommend,但检测到 boss-recruit(可能导致错误调用 recruit pipeline)。"
950
+ : "目标 Agent MCP 配置未检测到 boss-recommend。"
754
951
  });
755
952
  checks.push({
756
953
  key: `agent_${agentIntegration.agent}_skill`,
@@ -821,8 +1018,8 @@ function printHelp() {
821
1018
  console.log(" boss-recommend-mcp run Run the recommend pipeline once via CLI and print JSON");
822
1019
  console.log(" boss-recommend-mcp install Install skill and MCP config templates (supports --agent trae-cn/cursor/...)");
823
1020
  console.log(" boss-recommend-mcp install-skill Install only the Codex skill");
824
- console.log(" boss-recommend-mcp init-config Create ~/.boss-recommend-mcp/screening-config.json if missing");
825
- console.log(" boss-recommend-mcp config set Write baseUrl/apiKey/model to ~/.boss-recommend-mcp/screening-config.json");
1021
+ console.log(" boss-recommend-mcp init-config Create screening-config.json if missing (prefer workspace config/, fallback ~/.boss-recommend-mcp)");
1022
+ console.log(" boss-recommend-mcp config set Write baseUrl/apiKey/model (prefer workspace config/, fallback ~/.boss-recommend-mcp)");
826
1023
  console.log(" boss-recommend-mcp set-port Persist preferred Chrome debug port to screening-config.json");
827
1024
  console.log(" boss-recommend-mcp mcp-config Generate MCP config JSON for Cursor/Trae(含 trae-cn)/Claude Code/OpenClaw");
828
1025
  console.log(" boss-recommend-mcp doctor Check config and runtime prerequisites (supports --agent trae-cn/cursor/...)");
@@ -890,7 +1087,7 @@ async function runPipelineOnce(options) {
890
1087
  const explicitPort = parsePositivePort(options.port);
891
1088
  if (explicitPort) {
892
1089
  process.env.BOSS_RECOMMEND_CHROME_PORT = String(explicitPort);
893
- persistDebugPortSelection(explicitPort);
1090
+ persistDebugPortSelection(explicitPort, options);
894
1091
  }
895
1092
 
896
1093
  const result = await runRecommendPipeline({
@@ -937,7 +1134,7 @@ switch (command) {
937
1134
  console.log(`Skill installed to: ${installSkill()}`);
938
1135
  break;
939
1136
  case "init-config": {
940
- const result = ensureUserConfig();
1137
+ const result = ensureUserConfig(options);
941
1138
  console.log(result.created ? `Config template created at: ${result.path}` : `Config already exists at: ${result.path}`);
942
1139
  break;
943
1140
  }
package/src/index.js CHANGED
@@ -46,9 +46,51 @@ function createToolSchema() {
46
46
  properties: {
47
47
  filters_confirmed: { type: "boolean" },
48
48
  school_tag_confirmed: { type: "boolean" },
49
+ school_tag_value: {
50
+ oneOf: [
51
+ {
52
+ type: "string",
53
+ enum: ["不限", "985", "211", "双一流院校", "留学", "国内外名校", "公办本科"]
54
+ },
55
+ {
56
+ type: "array",
57
+ items: {
58
+ type: "string",
59
+ enum: ["不限", "985", "211", "双一流院校", "留学", "国内外名校", "公办本科"]
60
+ },
61
+ minItems: 1,
62
+ uniqueItems: true
63
+ }
64
+ ]
65
+ },
49
66
  degree_confirmed: { type: "boolean" },
67
+ degree_value: {
68
+ oneOf: [
69
+ {
70
+ type: "string",
71
+ enum: ["不限", "初中及以下", "中专/中技", "高中", "大专", "本科", "硕士", "博士"]
72
+ },
73
+ {
74
+ type: "array",
75
+ items: {
76
+ type: "string",
77
+ enum: ["不限", "初中及以下", "中专/中技", "高中", "大专", "本科", "硕士", "博士"]
78
+ },
79
+ minItems: 1,
80
+ uniqueItems: true
81
+ }
82
+ ]
83
+ },
50
84
  gender_confirmed: { type: "boolean" },
85
+ gender_value: {
86
+ type: "string",
87
+ enum: ["不限", "男", "女"]
88
+ },
51
89
  recent_not_view_confirmed: { type: "boolean" },
90
+ recent_not_view_value: {
91
+ type: "string",
92
+ enum: ["不限", "近14天没有"]
93
+ },
52
94
  criteria_confirmed: { type: "boolean" },
53
95
  target_count_confirmed: { type: "boolean" },
54
96
  target_count_value: {
@@ -60,6 +102,7 @@ function createToolSchema() {
60
102
  type: "string",
61
103
  enum: ["favorite", "greet"]
62
104
  },
105
+ final_confirmed: { type: "boolean" },
63
106
  job_confirmed: { type: "boolean" },
64
107
  job_value: { type: "string" },
65
108
  max_greet_count_confirmed: { type: "boolean" },
package/src/parser.js CHANGED
@@ -477,9 +477,13 @@ export function parseRecommendInstruction({ instruction, confirmation, overrides
477
477
  const detectedDegrees = extractDegrees(text);
478
478
  const schoolTagAudit = auditSchoolTagSelections(overrides?.school_tag);
479
479
  const overrideSchoolTag = schoolTagAudit.valid.length > 0 ? schoolTagAudit.valid : null;
480
+ const confirmationSchoolTag = normalizeSchoolTagSelections(confirmation?.school_tag_value);
480
481
  const overrideDegrees = normalizeDegreeSelections(overrides?.degree);
482
+ const confirmationDegrees = normalizeDegreeSelections(confirmation?.degree_value);
481
483
  const overrideGender = normalizeGender(overrides?.gender);
484
+ const confirmationGender = normalizeGender(confirmation?.gender_value);
482
485
  const overrideRecentNotView = normalizeRecentNotView(overrides?.recent_not_view);
486
+ const confirmationRecentNotView = normalizeRecentNotView(confirmation?.recent_not_view_value);
483
487
  const overrideCriteria = overrides?.criteria;
484
488
  const jobSelectionHint = normalizeText(overrides?.job || confirmation?.job_value || "");
485
489
 
@@ -487,16 +491,18 @@ export function parseRecommendInstruction({ instruction, confirmation, overrides
487
491
  ? sortSchoolTagSelections(detectedSchoolTags)
488
492
  : ["不限"];
489
493
  const searchParams = {
490
- school_tag: overrideSchoolTag || inferredSchoolTag,
494
+ school_tag: overrideSchoolTag || confirmationSchoolTag || inferredSchoolTag,
491
495
  degree: (
492
496
  (Array.isArray(overrideDegrees) && overrideDegrees.length > 0
493
497
  ? overrideDegrees
498
+ : Array.isArray(confirmationDegrees) && confirmationDegrees.length > 0
499
+ ? confirmationDegrees
494
500
  : Array.isArray(detectedDegrees) && detectedDegrees.length > 0
495
501
  ? detectedDegrees
496
502
  : ["不限"])
497
503
  ),
498
- gender: overrideGender || extractGender(text) || "不限",
499
- recent_not_view: overrideRecentNotView || extractRecentNotView(text) || "不限"
504
+ gender: overrideGender || confirmationGender || extractGender(text) || "不限",
505
+ recent_not_view: overrideRecentNotView || confirmationRecentNotView || extractRecentNotView(text) || "不限"
500
506
  };
501
507
  const screenParams = {
502
508
  criteria: buildCriteria(text, overrideCriteria),
@@ -525,10 +531,42 @@ export function parseRecommendInstruction({ instruction, confirmation, overrides
525
531
  invalidOverrideSchoolTags: schoolTagAudit.invalid,
526
532
  maxGreetCountResolution
527
533
  });
528
- const needs_school_tag_confirmation = confirmation?.school_tag_confirmed !== true;
529
- const needs_degree_confirmation = confirmation?.degree_confirmed !== true;
530
- const needs_gender_confirmation = confirmation?.gender_confirmed !== true;
531
- const needs_recent_not_view_confirmation = confirmation?.recent_not_view_confirmed !== true;
534
+ const hasSchoolTagSignal = Boolean(
535
+ (Array.isArray(overrideSchoolTag) && overrideSchoolTag.length > 0)
536
+ || (Array.isArray(confirmationSchoolTag) && confirmationSchoolTag.length > 0)
537
+ || (Array.isArray(detectedSchoolTags) && detectedSchoolTags.length > 0)
538
+ );
539
+ const hasDegreeSignal = Boolean(
540
+ (Array.isArray(overrideDegrees) && overrideDegrees.length > 0)
541
+ || (Array.isArray(confirmationDegrees) && confirmationDegrees.length > 0)
542
+ || (Array.isArray(detectedDegrees) && detectedDegrees.length > 0)
543
+ );
544
+ const hasGenderSignal = Boolean(
545
+ overrideGender
546
+ || confirmationGender
547
+ || extractGender(text)
548
+ );
549
+ const hasRecentNotViewSignal = Boolean(
550
+ overrideRecentNotView
551
+ || confirmationRecentNotView
552
+ || extractRecentNotView(text)
553
+ );
554
+ const needs_school_tag_confirmation = (
555
+ confirmation?.school_tag_confirmed !== true
556
+ || !hasSchoolTagSignal
557
+ );
558
+ const needs_degree_confirmation = (
559
+ confirmation?.degree_confirmed !== true
560
+ || !hasDegreeSignal
561
+ );
562
+ const needs_gender_confirmation = (
563
+ confirmation?.gender_confirmed !== true
564
+ || !hasGenderSignal
565
+ );
566
+ const needs_recent_not_view_confirmation = (
567
+ confirmation?.recent_not_view_confirmed !== true
568
+ || !hasRecentNotViewSignal
569
+ );
532
570
  const needs_filters_confirmation = (
533
571
  confirmation?.filters_confirmed !== true
534
572
  || needs_school_tag_confirmation
package/src/pipeline.js CHANGED
@@ -19,13 +19,14 @@ function normalizeText(value) {
19
19
  function normalizeJobTitle(value) {
20
20
  const text = normalizeText(value);
21
21
  if (!text) return "";
22
- const strippedRange = text
22
+ const byGap = text.split(/\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
23
+ const strippedRange = byGap
23
24
  .replace(/\s+\d+(?:\.\d+)?\s*(?:-|~|—|至)\s*\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)?$/u, "")
24
25
  .trim();
25
26
  const strippedSingle = strippedRange
26
27
  .replace(/\s+\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)$/u, "")
27
28
  .trim();
28
- return strippedSingle || text;
29
+ return strippedSingle || byGap;
29
30
  }
30
31
 
31
32
  function normalizeJobOptions(jobOptions = []) {
@@ -299,8 +300,8 @@ function buildNeedInputResponse(parsedResult) {
299
300
  };
300
301
  }
301
302
 
302
- function buildNeedConfirmationResponse(parsedResult) {
303
- return {
303
+ function buildNeedConfirmationResponse(parsedResult) {
304
+ return {
304
305
  status: "NEED_CONFIRMATION",
305
306
  required_confirmations: buildRequiredConfirmations(parsedResult),
306
307
  search_params: parsedResult.searchParams,
@@ -311,10 +312,22 @@ function buildNeedConfirmationResponse(parsedResult) {
311
312
  max_greet_count: parsedResult.proposed_max_greet_count || parsedResult.screenParams.max_greet_count
312
313
  },
313
314
  pending_questions: parsedResult.pending_questions,
314
- review: parsedResult.review
315
- };
316
- }
317
-
315
+ review: parsedResult.review
316
+ };
317
+ }
318
+
319
+ function buildFinalReviewQuestion({ searchParams, screenParams, selectedJob }) {
320
+ return {
321
+ field: "final_review",
322
+ question: "开始执行搜索和筛选前,请最后确认全部参数(岗位/筛选条件/筛选 criteria/目标人数/post_action/max_greet_count)无误。",
323
+ value: {
324
+ job: selectedJob?.title || selectedJob?.label || selectedJob?.value || null,
325
+ search_params: searchParams,
326
+ screen_params: screenParams
327
+ }
328
+ };
329
+ }
330
+
318
331
  function buildFailedResponse(code, message, extra = {}) {
319
332
  return {
320
333
  status: "FAILED",
@@ -445,7 +458,7 @@ export async function runRecommendPipeline(
445
458
  "2) apiKey",
446
459
  "3) model",
447
460
  `配置文件路径:${screenConfigCheck.path}`,
448
- "注意:不要使用模板占位符(例如 replace-with-openai-api-key)。填写完成后重试。"
461
+ "注意:不要使用模板占位符(例如 replace-with-openai-api-key),也不要由 agent 自行猜测或代填示例值。必须向用户逐项确认真实可用值后再重试。"
449
462
  ].join("\n")
450
463
  }
451
464
  : undefined,
@@ -559,6 +572,34 @@ export async function runRecommendPipeline(
559
572
  }
560
573
  const selectedJob = selectedJobResolution.job;
561
574
  const selectedJobToken = selectedJob.value || selectedJob.title || selectedJob.label;
575
+ if (confirmation?.final_confirmed !== true) {
576
+ const pendingQuestions = (parsed.pending_questions || []).filter((item) => item?.field !== "final_review");
577
+ pendingQuestions.push(buildFinalReviewQuestion({
578
+ searchParams: parsed.searchParams,
579
+ screenParams: {
580
+ ...parsed.screenParams,
581
+ target_count: parsed.proposed_target_count ?? parsed.screenParams.target_count,
582
+ post_action: parsed.proposed_post_action || parsed.screenParams.post_action,
583
+ max_greet_count: parsed.proposed_max_greet_count || parsed.screenParams.max_greet_count
584
+ },
585
+ selectedJob
586
+ }));
587
+ return {
588
+ status: "NEED_CONFIRMATION",
589
+ required_confirmations: dedupe([...buildRequiredConfirmations(parsed), "final_review"]),
590
+ search_params: parsed.searchParams,
591
+ screen_params: {
592
+ ...parsed.screenParams,
593
+ target_count: parsed.proposed_target_count ?? parsed.screenParams.target_count,
594
+ post_action: parsed.proposed_post_action || parsed.screenParams.post_action,
595
+ max_greet_count: parsed.proposed_max_greet_count || parsed.screenParams.max_greet_count
596
+ },
597
+ selected_job: selectedJob,
598
+ pending_questions: pendingQuestions,
599
+ review: parsed.review,
600
+ job_options: jobOptions
601
+ };
602
+ }
562
603
 
563
604
  const searchResult = await searchCli({
564
605
  workspaceRoot,
@@ -67,6 +67,70 @@ function testConfirmedPostActionAndOverrides() {
67
67
  assert.equal(result.needs_max_greet_count_confirmation, false);
68
68
  }
69
69
 
70
+ function testFilterConfirmedWithoutExplicitValuesShouldRequireReconfirmation() {
71
+ const result = parseRecommendInstruction({
72
+ instruction: "通过boss推荐skill帮我找人",
73
+ confirmation: {
74
+ filters_confirmed: true,
75
+ school_tag_confirmed: true,
76
+ degree_confirmed: true,
77
+ gender_confirmed: true,
78
+ recent_not_view_confirmed: true,
79
+ criteria_confirmed: true,
80
+ target_count_confirmed: true,
81
+ post_action_confirmed: true,
82
+ post_action_value: "favorite"
83
+ },
84
+ overrides: {
85
+ criteria: "必须有至少3年工作经验,且做过算法"
86
+ }
87
+ });
88
+
89
+ assert.deepEqual(result.searchParams.school_tag, ["不限"]);
90
+ assert.deepEqual(result.searchParams.degree, ["不限"]);
91
+ assert.equal(result.searchParams.gender, "不限");
92
+ assert.equal(result.searchParams.recent_not_view, "不限");
93
+ assert.equal(result.needs_school_tag_confirmation, true);
94
+ assert.equal(result.needs_degree_confirmation, true);
95
+ assert.equal(result.needs_gender_confirmation, true);
96
+ assert.equal(result.needs_recent_not_view_confirmation, true);
97
+ assert.equal(result.needs_filters_confirmation, true);
98
+ }
99
+
100
+ function testFilterConfirmedWithExplicitConfirmationValuesShouldNotFallbackToUnlimited() {
101
+ const result = parseRecommendInstruction({
102
+ instruction: "通过boss推荐skill帮我找人",
103
+ confirmation: {
104
+ filters_confirmed: true,
105
+ school_tag_confirmed: true,
106
+ school_tag_value: ["985", "211"],
107
+ degree_confirmed: true,
108
+ degree_value: ["大专", "本科", "硕士", "博士"],
109
+ gender_confirmed: true,
110
+ gender_value: "男",
111
+ recent_not_view_confirmed: true,
112
+ recent_not_view_value: "近14天没有",
113
+ criteria_confirmed: true,
114
+ target_count_confirmed: true,
115
+ target_count_value: 3,
116
+ post_action_confirmed: true,
117
+ post_action_value: "favorite"
118
+ },
119
+ overrides: {
120
+ criteria: "必须有至少3年工作经验,且做过算法"
121
+ }
122
+ });
123
+
124
+ assert.deepEqual(result.searchParams.school_tag, ["985", "211"]);
125
+ assert.deepEqual(result.searchParams.degree, ["大专", "本科", "硕士", "博士"]);
126
+ assert.equal(result.searchParams.gender, "男");
127
+ assert.equal(result.searchParams.recent_not_view, "近14天没有");
128
+ assert.equal(result.needs_school_tag_confirmation, false);
129
+ assert.equal(result.needs_degree_confirmation, false);
130
+ assert.equal(result.needs_gender_confirmation, false);
131
+ assert.equal(result.needs_recent_not_view_confirmation, false);
132
+ }
133
+
70
134
  function testMultipleSchoolTagsMarkedSuspicious() {
71
135
  const result = parseRecommendInstruction({
72
136
  instruction: "推荐页筛选985和211,有推荐系统经验",
@@ -351,6 +415,8 @@ function testMcpMentionShouldStayInCriteria() {
351
415
  function main() {
352
416
  testNeedConfirmationIncludesPostAction();
353
417
  testConfirmedPostActionAndOverrides();
418
+ testFilterConfirmedWithoutExplicitValuesShouldRequireReconfirmation();
419
+ testFilterConfirmedWithExplicitConfirmationValuesShouldNotFallbackToUnlimited();
354
420
  testMultipleSchoolTagsMarkedSuspicious();
355
421
  testDegreeCanBeExtracted();
356
422
  testDegreeAtOrAboveExpansion();
@@ -30,6 +30,14 @@ function createJobListResult() {
30
30
  }
31
31
 
32
32
  function createJobConfirmedConfirmation() {
33
+ return {
34
+ job_confirmed: true,
35
+ job_value: "数据分析实习生 _ 杭州",
36
+ final_confirmed: true
37
+ };
38
+ }
39
+
40
+ function createJobConfirmedWithoutFinalConfirmation() {
33
41
  return {
34
42
  job_confirmed: true,
35
43
  job_value: "数据分析实习生 _ 杭州"
@@ -318,6 +326,29 @@ async function testNeedJobConfirmationGate() {
318
326
  assert.equal(result.job_options.length, 2);
319
327
  }
320
328
 
329
+ async function testNeedFinalReviewConfirmationGate() {
330
+ const result = await runRecommendPipeline(
331
+ {
332
+ workspaceRoot: process.cwd(),
333
+ instruction: "test",
334
+ confirmation: createJobConfirmedWithoutFinalConfirmation(),
335
+ overrides: {}
336
+ },
337
+ {
338
+ parseRecommendInstruction: () => createParsed(),
339
+ runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
340
+ ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: {} }),
341
+ listRecommendJobs: async () => createJobListResult(),
342
+ runRecommendSearchCli: async () => ({ ok: true, summary: {} }),
343
+ runRecommendScreenCli: async () => ({ ok: true, summary: {} })
344
+ }
345
+ );
346
+
347
+ assert.equal(result.status, "NEED_CONFIRMATION");
348
+ assert.equal(result.required_confirmations.includes("final_review"), true);
349
+ assert.equal(result.pending_questions.some((item) => item.field === "final_review"), true);
350
+ }
351
+
321
352
  async function testLoginRequiredShouldReturnGuidance() {
322
353
  const result = await runRecommendPipeline(
323
354
  {
@@ -587,6 +618,7 @@ async function main() {
587
618
  await testNeedMaxGreetCountConfirmationGate();
588
619
  await testNeedInputGate();
589
620
  await testNeedJobConfirmationGate();
621
+ await testNeedFinalReviewConfirmationGate();
590
622
  await testCompletedPipeline();
591
623
  await testSearchFailure();
592
624
  await testLoginRequiredShouldReturnGuidance();
@@ -19,13 +19,14 @@ function normalizeText(value) {
19
19
  function normalizeJobTitle(value) {
20
20
  const text = normalizeText(value);
21
21
  if (!text) return "";
22
- const strippedRange = text
22
+ const byGap = text.split(/\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
23
+ const strippedRange = byGap
23
24
  .replace(/\s+\d+(?:\.\d+)?\s*(?:-|~|—|至)\s*\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)?$/u, "")
24
25
  .trim();
25
26
  const strippedSingle = strippedRange
26
27
  .replace(/\s+\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)$/u, "")
27
28
  .trim();
28
- return strippedSingle || text;
29
+ return strippedSingle || byGap;
29
30
  }
30
31
 
31
32
  function parsePositiveInteger(raw) {
@@ -419,13 +420,14 @@ class RecommendSearchCli {
419
420
  const normalizeTitle = (value) => {
420
421
  const text = normalize(value);
421
422
  if (!text) return '';
422
- const strippedRange = text
423
+ const byGap = text.split(/\\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
424
+ const strippedRange = byGap
423
425
  .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:-|~|—|至)\\s*\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)?$/u, '')
424
426
  .trim();
425
427
  const strippedSingle = strippedRange
426
428
  .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)$/u, '')
427
429
  .trim();
428
- return strippedSingle || text;
430
+ return strippedSingle || byGap;
429
431
  };
430
432
  const isVisible = (el) => {
431
433
  if (!el) return false;
@@ -437,9 +439,13 @@ class RecommendSearchCli {
437
439
  return rect.width > 2 && rect.height > 2;
438
440
  };
439
441
 
440
- const items = Array.from(
441
- doc.querySelectorAll('.ui-dropmenu-list .job-list .job-item, .job-selecter-options .job-list .job-item, .job-list .job-item')
442
- );
442
+ const items = Array.from(doc.querySelectorAll([
443
+ '.ui-dropmenu-list .job-list .job-item',
444
+ '.job-selecter-options .job-list .job-item',
445
+ '.job-selector-options .job-list .job-item',
446
+ '.dropmenu-list .job-list .job-item',
447
+ '.job-list .job-item'
448
+ ].join(',')));
443
449
  const jobs = [];
444
450
  const seen = new Set();
445
451
  for (const item of items) {
@@ -481,9 +487,13 @@ class RecommendSearchCli {
481
487
  const doc = frame.contentDocument;
482
488
  const selectors = [
483
489
  '.chat-job-select',
490
+ '.chat-job-selector',
484
491
  '.job-selecter',
492
+ '.job-selector',
485
493
  '.job-select-wrap',
486
494
  '.job-select',
495
+ '.job-select-box',
496
+ '.job-wrap',
487
497
  '.chat-job-name',
488
498
  '.top-chat-search'
489
499
  ];
@@ -564,17 +574,22 @@ class RecommendSearchCli {
564
574
  const normalizeTitle = (value) => {
565
575
  const text = normalize(value);
566
576
  if (!text) return '';
567
- const strippedRange = text
577
+ const byGap = text.split(/\\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
578
+ const strippedRange = byGap
568
579
  .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:-|~|—|至)\\s*\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)?$/u, '')
569
580
  .trim();
570
581
  const strippedSingle = strippedRange
571
582
  .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)$/u, '')
572
583
  .trim();
573
- return strippedSingle || text;
584
+ return strippedSingle || byGap;
574
585
  };
575
- const items = Array.from(
576
- doc.querySelectorAll('.ui-dropmenu-list .job-list .job-item, .job-selecter-options .job-list .job-item, .job-list .job-item')
577
- );
586
+ const items = Array.from(doc.querySelectorAll([
587
+ '.ui-dropmenu-list .job-list .job-item',
588
+ '.job-selecter-options .job-list .job-item',
589
+ '.job-selector-options .job-list .job-item',
590
+ '.dropmenu-list .job-list .job-item',
591
+ '.job-list .job-item'
592
+ ].join(',')));
578
593
  const target = items.find((item) => {
579
594
  const value = normalize(item.getAttribute('value') || item.dataset?.value || '');
580
595
  const label = normalize(item.querySelector('.label')?.textContent || item.textContent || '');