job-forge 2.14.42 → 2.14.44
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/.cursor/rules/main.mdc +4 -1
- package/AGENTS.md +4 -1
- package/CLAUDE.md +4 -1
- package/bin/create-job-forge.mjs +4 -0
- package/bin/geometra-mcp-launcher.mjs +191 -3
- package/bin/job-forge.mjs +27 -0
- package/docs/ARCHITECTURE.md +2 -1
- package/docs/SETUP.md +2 -0
- package/iso/instructions.md +4 -1
- package/modes/apply.md +15 -6
- package/modes/auto-pipeline.md +2 -2
- package/modes/pipeline.md +6 -4
- package/modes/reference-geometra.md +2 -0
- package/modes/reference-local-helpers.md +3 -0
- package/modes/reference-portals.md +20 -0
- package/package.json +5 -1
- package/scripts/check-helper-integration.mjs +1 -0
- package/scripts/check-iso-smoke.mjs +2 -0
- package/scripts/portal.mjs +369 -0
- package/templates/migrations.json +4 -0
package/.cursor/rules/main.mdc
CHANGED
|
@@ -36,6 +36,9 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
36
36
|
- [H8] Never paste proxy values from `config/profile.yml` into `task` prompts, status text, or summaries. If a proxy is configured, tell the subagent exactly: "Proxy is configured; read `config/profile.yml` and pass its top-level `proxy:` object plus `headless: true` and `stealth: true` to every `geometra_connect` call and every Geometra auto-connect call that passes `pageUrl` or `url`." Do not transcribe `server`, `username`, `password`, or `bypass`, even if you just read them from disk.
|
|
37
37
|
why: a 2026-04-25 OpenCode trace showed raw proxy credentials copied into an apply subagent prompt; trace logs are local, but prompts must still avoid replicating secrets across subagent sessions. Geometra MCP opens visible Chromium unless `headless: true` is explicit, and Geometra MCP >=1.61.3 can launch CloakBrowser stealth Chromium via `stealth: true`; both flags belong with JobForge portal sessions instead of stock visible Playwright Chromium
|
|
38
38
|
|
|
39
|
+
- [H9] If Geometra MCP disappears, becomes unresponsive, or returns a cascade of `Not connected` after a live form-fill, inspect `.jobforge-mcp/geometra-mcp.jsonl` before guessing. Report the last `launcher_start`, `child_spawn`, `heartbeat`, `signal_received`, `child_stderr`, and `child_exit` events plus the timestamp gap from the last heartbeat. If the last event is an old heartbeat with no `signal_received` / `child_exit`, treat it as likely host SIGKILL or external process death.
|
|
40
|
+
why: OpenCode or the OS can kill the MCP server without stderr, crash logs, or core dumps. JobForge's MCP launcher writes durable lifecycle events outside MCP stdout, so silent disappearances still leave enough evidence to distinguish host kill, child crash, stderr failure, and wrapper health
|
|
41
|
+
|
|
39
42
|
## Defaults
|
|
40
43
|
|
|
41
44
|
- [D1] Delegate to a subagent (`task`) only when the work involves repeated tool-heavy steps that bloat the cache prefix: applying to N≥2 jobs, batch scans hitting ≥3 companies, or any "apply to… / process pipeline / batch evaluate" user phrasing. Single-offer evals, dev work, file edits, `tracker` mode, single-URL checks, and one-shot questions stay inline.
|
|
@@ -67,7 +70,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
67
70
|
1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
|
|
68
71
|
2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
|
|
69
72
|
3. Read the active mode file [D3]. Use local helpers when they can replace broad file reads, prose math, manual policy checks, or artifact reuse decisions [D8]. Decide inline vs delegated work [D1].
|
|
70
|
-
4. Prepare Geometra dispatches: cleanup [H3], local-helper prefilters when useful [D8], dedupe [H2], location filter [D5], file-backed preflight plan/check [D8], routing [D2], proxy/headless/stealth prompt hygiene [H8].
|
|
73
|
+
4. Prepare Geometra dispatches: cleanup [H3], local-helper prefilters when useful [D8], dedupe [H2], location filter [D5], file-backed preflight plan/check [D8], routing [D2], proxy/headless/stealth prompt hygiene [H8], MCP lifecycle log awareness [H9].
|
|
71
74
|
5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b], then settle the round with postflight status [D8].
|
|
72
75
|
6. Keep multi-job form-filling out of the orchestrator [H4].
|
|
73
76
|
7. Cross-check subagent facts against authoritative files [H7].
|
package/AGENTS.md
CHANGED
|
@@ -31,6 +31,9 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
31
31
|
- [H8] Never paste proxy values from `config/profile.yml` into `task` prompts, status text, or summaries. If a proxy is configured, tell the subagent exactly: "Proxy is configured; read `config/profile.yml` and pass its top-level `proxy:` object plus `headless: true` and `stealth: true` to every `geometra_connect` call and every Geometra auto-connect call that passes `pageUrl` or `url`." Do not transcribe `server`, `username`, `password`, or `bypass`, even if you just read them from disk.
|
|
32
32
|
why: a 2026-04-25 OpenCode trace showed raw proxy credentials copied into an apply subagent prompt; trace logs are local, but prompts must still avoid replicating secrets across subagent sessions. Geometra MCP opens visible Chromium unless `headless: true` is explicit, and Geometra MCP >=1.61.3 can launch CloakBrowser stealth Chromium via `stealth: true`; both flags belong with JobForge portal sessions instead of stock visible Playwright Chromium
|
|
33
33
|
|
|
34
|
+
- [H9] If Geometra MCP disappears, becomes unresponsive, or returns a cascade of `Not connected` after a live form-fill, inspect `.jobforge-mcp/geometra-mcp.jsonl` before guessing. Report the last `launcher_start`, `child_spawn`, `heartbeat`, `signal_received`, `child_stderr`, and `child_exit` events plus the timestamp gap from the last heartbeat. If the last event is an old heartbeat with no `signal_received` / `child_exit`, treat it as likely host SIGKILL or external process death.
|
|
35
|
+
why: OpenCode or the OS can kill the MCP server without stderr, crash logs, or core dumps. JobForge's MCP launcher writes durable lifecycle events outside MCP stdout, so silent disappearances still leave enough evidence to distinguish host kill, child crash, stderr failure, and wrapper health
|
|
36
|
+
|
|
34
37
|
## Defaults
|
|
35
38
|
|
|
36
39
|
- [D1] Delegate to a subagent (`task`) only when the work involves repeated tool-heavy steps that bloat the cache prefix: applying to N≥2 jobs, batch scans hitting ≥3 companies, or any "apply to… / process pipeline / batch evaluate" user phrasing. Single-offer evals, dev work, file edits, `tracker` mode, single-URL checks, and one-shot questions stay inline.
|
|
@@ -62,7 +65,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
62
65
|
1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
|
|
63
66
|
2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
|
|
64
67
|
3. Read the active mode file [D3]. Use local helpers when they can replace broad file reads, prose math, manual policy checks, or artifact reuse decisions [D8]. Decide inline vs delegated work [D1].
|
|
65
|
-
4. Prepare Geometra dispatches: cleanup [H3], local-helper prefilters when useful [D8], dedupe [H2], location filter [D5], file-backed preflight plan/check [D8], routing [D2], proxy/headless/stealth prompt hygiene [H8].
|
|
68
|
+
4. Prepare Geometra dispatches: cleanup [H3], local-helper prefilters when useful [D8], dedupe [H2], location filter [D5], file-backed preflight plan/check [D8], routing [D2], proxy/headless/stealth prompt hygiene [H8], MCP lifecycle log awareness [H9].
|
|
66
69
|
5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b], then settle the round with postflight status [D8].
|
|
67
70
|
6. Keep multi-job form-filling out of the orchestrator [H4].
|
|
68
71
|
7. Cross-check subagent facts against authoritative files [H7].
|
package/CLAUDE.md
CHANGED
|
@@ -31,6 +31,9 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
31
31
|
- [H8] Never paste proxy values from `config/profile.yml` into `task` prompts, status text, or summaries. If a proxy is configured, tell the subagent exactly: "Proxy is configured; read `config/profile.yml` and pass its top-level `proxy:` object plus `headless: true` and `stealth: true` to every `geometra_connect` call and every Geometra auto-connect call that passes `pageUrl` or `url`." Do not transcribe `server`, `username`, `password`, or `bypass`, even if you just read them from disk.
|
|
32
32
|
why: a 2026-04-25 OpenCode trace showed raw proxy credentials copied into an apply subagent prompt; trace logs are local, but prompts must still avoid replicating secrets across subagent sessions. Geometra MCP opens visible Chromium unless `headless: true` is explicit, and Geometra MCP >=1.61.3 can launch CloakBrowser stealth Chromium via `stealth: true`; both flags belong with JobForge portal sessions instead of stock visible Playwright Chromium
|
|
33
33
|
|
|
34
|
+
- [H9] If Geometra MCP disappears, becomes unresponsive, or returns a cascade of `Not connected` after a live form-fill, inspect `.jobforge-mcp/geometra-mcp.jsonl` before guessing. Report the last `launcher_start`, `child_spawn`, `heartbeat`, `signal_received`, `child_stderr`, and `child_exit` events plus the timestamp gap from the last heartbeat. If the last event is an old heartbeat with no `signal_received` / `child_exit`, treat it as likely host SIGKILL or external process death.
|
|
35
|
+
why: OpenCode or the OS can kill the MCP server without stderr, crash logs, or core dumps. JobForge's MCP launcher writes durable lifecycle events outside MCP stdout, so silent disappearances still leave enough evidence to distinguish host kill, child crash, stderr failure, and wrapper health
|
|
36
|
+
|
|
34
37
|
## Defaults
|
|
35
38
|
|
|
36
39
|
- [D1] Delegate to a subagent (`task`) only when the work involves repeated tool-heavy steps that bloat the cache prefix: applying to N≥2 jobs, batch scans hitting ≥3 companies, or any "apply to… / process pipeline / batch evaluate" user phrasing. Single-offer evals, dev work, file edits, `tracker` mode, single-URL checks, and one-shot questions stay inline.
|
|
@@ -62,7 +65,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
62
65
|
1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
|
|
63
66
|
2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
|
|
64
67
|
3. Read the active mode file [D3]. Use local helpers when they can replace broad file reads, prose math, manual policy checks, or artifact reuse decisions [D8]. Decide inline vs delegated work [D1].
|
|
65
|
-
4. Prepare Geometra dispatches: cleanup [H3], local-helper prefilters when useful [D8], dedupe [H2], location filter [D5], file-backed preflight plan/check [D8], routing [D2], proxy/headless/stealth prompt hygiene [H8].
|
|
68
|
+
4. Prepare Geometra dispatches: cleanup [H3], local-helper prefilters when useful [D8], dedupe [H2], location filter [D5], file-backed preflight plan/check [D8], routing [D2], proxy/headless/stealth prompt hygiene [H8], MCP lifecycle log awareness [H9].
|
|
66
69
|
5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b], then settle the round with postflight status [D8].
|
|
67
70
|
6. Keep multi-job form-filling out of the orchestrator [H4].
|
|
68
71
|
7. Cross-check subagent facts against authoritative files [H7].
|
package/bin/create-job-forge.mjs
CHANGED
|
@@ -110,6 +110,9 @@ const consumerPkg = {
|
|
|
110
110
|
tokens: 'job-forge tokens',
|
|
111
111
|
'tokens:today': 'job-forge tokens --days 1',
|
|
112
112
|
'tokens:log': 'job-forge tokens --days 1 --append',
|
|
113
|
+
'portal:snapshot': 'job-forge portal:snapshot',
|
|
114
|
+
'portal:form-schema': 'job-forge portal:form-schema',
|
|
115
|
+
'portal:explain': 'job-forge portal:explain',
|
|
113
116
|
'trace:list': 'job-forge trace:list',
|
|
114
117
|
'trace:stats': 'job-forge trace:stats',
|
|
115
118
|
'trace:show': 'job-forge trace:show',
|
|
@@ -411,6 +414,7 @@ data/timeline-events.jsonl
|
|
|
411
414
|
.jobforge-prioritize.json
|
|
412
415
|
.jobforge-prioritize-items.json
|
|
413
416
|
.jobforge-lineage.json
|
|
417
|
+
.jobforge-mcp/
|
|
414
418
|
.jobforge-runs/
|
|
415
419
|
.jobforge-redacted/
|
|
416
420
|
reports/
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
|
|
8
|
-
const DEFAULT_FALLBACK_PACKAGE = '@geometra/mcp@1.
|
|
8
|
+
const DEFAULT_FALLBACK_PACKAGE = '@geometra/mcp@1.62.1';
|
|
9
9
|
const RESOLVE_ONLY_FLAG = '--job-forge-resolve-target';
|
|
10
|
+
const DEFAULT_LOG_RELATIVE_PATH = '.jobforge-mcp/geometra-mcp.jsonl';
|
|
11
|
+
const DEFAULT_HEARTBEAT_MS = 15_000;
|
|
12
|
+
const MAX_STDERR_LOG_CHARS = 4_000;
|
|
13
|
+
const SIGNAL_EXIT_CODES = {
|
|
14
|
+
SIGHUP: 129,
|
|
15
|
+
SIGINT: 130,
|
|
16
|
+
SIGQUIT: 131,
|
|
17
|
+
SIGTERM: 143,
|
|
18
|
+
};
|
|
10
19
|
|
|
11
20
|
function normalizeEnv(value) {
|
|
12
21
|
if (typeof value !== 'string') return null;
|
|
@@ -26,6 +35,94 @@ function readJsonFile(filePath) {
|
|
|
26
35
|
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
27
36
|
}
|
|
28
37
|
|
|
38
|
+
function boolEnvDisabled(value) {
|
|
39
|
+
if (typeof value !== 'string') return false;
|
|
40
|
+
return ['0', 'false', 'no', 'off'].includes(value.trim().toLowerCase());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function positiveIntEnv(value, fallback) {
|
|
44
|
+
if (typeof value !== 'string' || value.trim() === '') return fallback;
|
|
45
|
+
const parsed = Number.parseInt(value, 10);
|
|
46
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createLifecycleLogger(projectDir) {
|
|
50
|
+
if (boolEnvDisabled(process.env.JOB_FORGE_GEOMETRA_MCP_LOG)) {
|
|
51
|
+
return {
|
|
52
|
+
enabled: false,
|
|
53
|
+
logPath: null,
|
|
54
|
+
write() {},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const configuredPath = normalizeEnv(process.env.JOB_FORGE_GEOMETRA_MCP_LOG_PATH);
|
|
59
|
+
const logPath = configuredPath ? resolve(configuredPath) : join(projectDir, DEFAULT_LOG_RELATIVE_PATH);
|
|
60
|
+
|
|
61
|
+
function write(event, detail = {}) {
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
64
|
+
appendFileSync(logPath, `${JSON.stringify({
|
|
65
|
+
ts: new Date().toISOString(),
|
|
66
|
+
event,
|
|
67
|
+
pid: process.pid,
|
|
68
|
+
ppid: process.ppid,
|
|
69
|
+
projectDir,
|
|
70
|
+
...detail,
|
|
71
|
+
})}\n`);
|
|
72
|
+
} catch {
|
|
73
|
+
// Logging must never break MCP startup or stdio protocol handling.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
enabled: true,
|
|
79
|
+
logPath,
|
|
80
|
+
write,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function targetForLog(target) {
|
|
85
|
+
return {
|
|
86
|
+
source: target.source,
|
|
87
|
+
command: target.command,
|
|
88
|
+
args: target.args,
|
|
89
|
+
resolvedPath: target.resolvedPath,
|
|
90
|
+
packageSpec: target.packageSpec,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function envForLog() {
|
|
95
|
+
return {
|
|
96
|
+
node: process.version,
|
|
97
|
+
platform: process.platform,
|
|
98
|
+
arch: process.arch,
|
|
99
|
+
geometraStealth: process.env.GEOMETRA_STEALTH ?? null,
|
|
100
|
+
geometraBrowser: process.env.GEOMETRA_BROWSER ?? null,
|
|
101
|
+
explicitMcpPath: Boolean(normalizeEnv(process.env.JOB_FORGE_GEOMETRA_MCP_PATH)),
|
|
102
|
+
explicitMcpPackage: Boolean(normalizeEnv(process.env.JOB_FORGE_GEOMETRA_MCP_PACKAGE)),
|
|
103
|
+
explicitLogPath: Boolean(normalizeEnv(process.env.JOB_FORGE_GEOMETRA_MCP_LOG_PATH)),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function processHealthForLog() {
|
|
108
|
+
const memory = process.memoryUsage();
|
|
109
|
+
const resourceUsage = typeof process.resourceUsage === 'function' ? process.resourceUsage() : null;
|
|
110
|
+
return {
|
|
111
|
+
uptimeMs: Math.round(process.uptime() * 1000),
|
|
112
|
+
memory,
|
|
113
|
+
resourceUsage,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function stderrChunkForLog(chunk) {
|
|
118
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
|
|
119
|
+
return {
|
|
120
|
+
text: text.slice(0, MAX_STDERR_LOG_CHARS),
|
|
121
|
+
bytes: Buffer.byteLength(text),
|
|
122
|
+
truncated: text.length > MAX_STDERR_LOG_CHARS,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
29
126
|
function readProjectPathFromPackageJson(projectDir) {
|
|
30
127
|
const packagePath = join(projectDir, 'package.json');
|
|
31
128
|
if (!existsSync(packagePath)) return null;
|
|
@@ -114,18 +211,109 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
114
211
|
return;
|
|
115
212
|
}
|
|
116
213
|
|
|
214
|
+
const projectDir = process.env.JOB_FORGE_PROJECT || process.cwd();
|
|
215
|
+
const logger = createLifecycleLogger(projectDir);
|
|
216
|
+
const heartbeatMs = boolEnvDisabled(process.env.JOB_FORGE_GEOMETRA_MCP_HEARTBEAT)
|
|
217
|
+
? 0
|
|
218
|
+
: positiveIntEnv(process.env.JOB_FORGE_GEOMETRA_MCP_HEARTBEAT_MS, DEFAULT_HEARTBEAT_MS);
|
|
219
|
+
|
|
220
|
+
logger.write('launcher_start', {
|
|
221
|
+
argv,
|
|
222
|
+
target: targetForLog(target),
|
|
223
|
+
env: envForLog(),
|
|
224
|
+
logPath: logger.logPath,
|
|
225
|
+
heartbeatMs,
|
|
226
|
+
});
|
|
227
|
+
|
|
117
228
|
const child = spawn(target.command, [...target.args, ...argv], {
|
|
118
|
-
stdio: 'inherit',
|
|
229
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
119
230
|
env: process.env,
|
|
120
231
|
});
|
|
232
|
+
let exiting = false;
|
|
233
|
+
let heartbeat = null;
|
|
234
|
+
|
|
235
|
+
logger.write('child_spawn', {
|
|
236
|
+
childPid: child.pid,
|
|
237
|
+
target: targetForLog(target),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (child.stderr) {
|
|
241
|
+
child.stderr.on('data', (chunk) => {
|
|
242
|
+
process.stderr.write(chunk);
|
|
243
|
+
logger.write('child_stderr', {
|
|
244
|
+
childPid: child.pid,
|
|
245
|
+
...stderrChunkForLog(chunk),
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (heartbeatMs > 0) {
|
|
251
|
+
heartbeat = setInterval(() => {
|
|
252
|
+
logger.write('heartbeat', {
|
|
253
|
+
childPid: child.pid,
|
|
254
|
+
childKilled: child.killed,
|
|
255
|
+
health: processHealthForLog(),
|
|
256
|
+
});
|
|
257
|
+
}, heartbeatMs);
|
|
258
|
+
heartbeat.unref();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const signalNames = ['SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGTERM'];
|
|
262
|
+
for (const signal of signalNames) {
|
|
263
|
+
process.once(signal, () => {
|
|
264
|
+
if (exiting) return;
|
|
265
|
+
exiting = true;
|
|
266
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
267
|
+
logger.write('signal_received', {
|
|
268
|
+
signal,
|
|
269
|
+
childPid: child.pid,
|
|
270
|
+
childKilled: child.killed,
|
|
271
|
+
health: processHealthForLog(),
|
|
272
|
+
});
|
|
273
|
+
if (!child.killed) child.kill(signal);
|
|
274
|
+
process.exit(SIGNAL_EXIT_CODES[signal] ?? 1);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
process.once('uncaughtException', (error) => {
|
|
279
|
+
logger.write('uncaught_exception', {
|
|
280
|
+
message: error instanceof Error ? error.message : String(error),
|
|
281
|
+
stack: error instanceof Error ? error.stack : null,
|
|
282
|
+
health: processHealthForLog(),
|
|
283
|
+
});
|
|
284
|
+
throw error;
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
process.once('unhandledRejection', (reason) => {
|
|
288
|
+
logger.write('unhandled_rejection', {
|
|
289
|
+
message: reason instanceof Error ? reason.message : String(reason),
|
|
290
|
+
stack: reason instanceof Error ? reason.stack : null,
|
|
291
|
+
health: processHealthForLog(),
|
|
292
|
+
});
|
|
293
|
+
});
|
|
121
294
|
|
|
122
295
|
child.on('error', (error) => {
|
|
296
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
297
|
+
logger.write('child_error', {
|
|
298
|
+
childPid: child.pid,
|
|
299
|
+
message: error instanceof Error ? error.message : String(error),
|
|
300
|
+
stack: error instanceof Error ? error.stack : null,
|
|
301
|
+
health: processHealthForLog(),
|
|
302
|
+
});
|
|
123
303
|
console.error(error instanceof Error ? error.message : String(error));
|
|
124
304
|
process.exit(1);
|
|
125
305
|
});
|
|
126
306
|
|
|
127
307
|
child.on('exit', (code, signal) => {
|
|
308
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
309
|
+
logger.write('child_exit', {
|
|
310
|
+
childPid: child.pid,
|
|
311
|
+
code,
|
|
312
|
+
signal,
|
|
313
|
+
health: processHealthForLog(),
|
|
314
|
+
});
|
|
128
315
|
if (signal) {
|
|
316
|
+
for (const signalName of signalNames) process.removeAllListeners(signalName);
|
|
129
317
|
process.kill(process.pid, signal);
|
|
130
318
|
return;
|
|
131
319
|
}
|
package/bin/job-forge.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* pdf Run generate-pdf.mjs
|
|
18
18
|
* sync-check Run cv-sync-check.mjs
|
|
19
19
|
* mcp:geometra Launch Geometra MCP via JobForge's local/npm resolver
|
|
20
|
+
* portal:* One-shot direct-Geometra browser snapshots/form schemas
|
|
20
21
|
* tokens Run scripts/token-usage-report.mjs
|
|
21
22
|
* trace:* Inspect local agent transcripts via iso-trace
|
|
22
23
|
* telemetry:* Summarize JobForge pipeline status from traces + tracker files
|
|
@@ -91,6 +92,12 @@ const guardAliases = {
|
|
|
91
92
|
'guard:explain': 'explain',
|
|
92
93
|
};
|
|
93
94
|
|
|
95
|
+
const portalAliases = {
|
|
96
|
+
'portal:snapshot': 'snapshot',
|
|
97
|
+
'portal:form-schema': 'form-schema',
|
|
98
|
+
'portal:explain': 'explain',
|
|
99
|
+
};
|
|
100
|
+
|
|
94
101
|
const ledgerAliases = {
|
|
95
102
|
'ledger:status': 'status',
|
|
96
103
|
'ledger:rebuild': 'rebuild',
|
|
@@ -247,6 +254,9 @@ Commands:
|
|
|
247
254
|
pdf Generate ATS-optimized CV PDF from cv.md
|
|
248
255
|
sync-check Lint: verify cv.md and profile.yml are filled in
|
|
249
256
|
mcp:geometra Launch Geometra MCP, preferring local JobForge/Geometra dev wiring
|
|
257
|
+
portal:snapshot Render a URL with direct Geometra and print page model/snapshot
|
|
258
|
+
portal:form-schema Render a URL with direct Geometra and print form schema
|
|
259
|
+
portal:explain Show direct Geometra module/defaults
|
|
250
260
|
tokens Show opencode token usage and cost by session/day
|
|
251
261
|
trace Pass through to iso-trace (e.g. job-forge trace sources)
|
|
252
262
|
trace:list List recent local agent sessions (defaults: --since 7d --cwd project)
|
|
@@ -354,6 +364,8 @@ Pass --help after a command to see its own flags, e.g.:
|
|
|
354
364
|
job-forge telemetry:show ses_...
|
|
355
365
|
job-forge guard:audit
|
|
356
366
|
job-forge guard:explain
|
|
367
|
+
job-forge portal:snapshot --url https://example.test/jobs/123 --json
|
|
368
|
+
job-forge portal:form-schema --url https://example.test/apply --json
|
|
357
369
|
job-forge ledger:has --company "Acme" --role "Staff Engineer" --status Applied
|
|
358
370
|
job-forge capabilities:explain general-free
|
|
359
371
|
job-forge capabilities:check general-free --tool browser --mcp geometra --command "npx job-forge merge" --filesystem write
|
|
@@ -438,6 +450,21 @@ if (cmd === 'guard' || guardAliases[cmd]) {
|
|
|
438
450
|
process.exit(result.status ?? 1);
|
|
439
451
|
}
|
|
440
452
|
|
|
453
|
+
if (cmd === 'portal' || portalAliases[cmd]) {
|
|
454
|
+
const portalArgs = cmd === 'portal'
|
|
455
|
+
? (rest.length === 0 ? ['help'] : rest)
|
|
456
|
+
: [portalAliases[cmd], ...rest];
|
|
457
|
+
|
|
458
|
+
const scriptPath = join(PKG_ROOT, 'scripts/portal.mjs');
|
|
459
|
+
const result = spawnSync(process.execPath, [scriptPath, ...portalArgs], {
|
|
460
|
+
stdio: 'inherit',
|
|
461
|
+
cwd: PROJECT_DIR,
|
|
462
|
+
env: process.env,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
process.exit(result.status ?? 1);
|
|
466
|
+
}
|
|
467
|
+
|
|
441
468
|
if (cmd === 'ledger' || ledgerAliases[cmd]) {
|
|
442
469
|
const ledgerArgs = cmd === 'ledger'
|
|
443
470
|
? (rest.length === 0 ? ['help'] : rest)
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -115,7 +115,7 @@ For customization (archetypes, weights, tone), start with `_shared.md` and [CUST
|
|
|
115
115
|
## Evaluation Flow (Single Offer)
|
|
116
116
|
|
|
117
117
|
1. **Input**: User pastes JD text or URL
|
|
118
|
-
2. **Extract**: Geometra MCP
|
|
118
|
+
2. **Extract**: `job-forge portal:*`, Geometra MCP, or WebFetch extracts JD/form context from URL
|
|
119
119
|
3. **Classify**: Detect archetype (one row from the archetype table in `modes/_shared.md`)
|
|
120
120
|
4. **Evaluate**: 6 blocks (A-F).
|
|
121
121
|
- A: Role summary.
|
|
@@ -251,6 +251,7 @@ Scripts maintain data consistency. In a consumer project they're invoked via the
|
|
|
251
251
|
| `scripts/trace.mjs` | `npx job-forge trace:list` / `trace:stats` / `trace:show` | Local transcript observability via `@agent-pattern-labs/iso-trace`; common commands default to project-local sessions across supported harnesses |
|
|
252
252
|
| `scripts/telemetry.mjs` | `npx job-forge telemetry:status` / `telemetry:show` | JobForge operational telemetry derived from normalized local traces plus tracker TSV state |
|
|
253
253
|
| `scripts/guard.mjs` | `npx job-forge guard:audit` / `guard:explain` | Deterministic `@agent-pattern-labs/iso-guard` policy audits over local normalized traces (with OpenCode `task` rules still available where relevant) |
|
|
254
|
+
| `scripts/portal.mjs` | `npx job-forge portal:snapshot` / `portal:form-schema` | Deterministic direct-Geometra one-shot browser snapshots and form schemas with JobForge browser defaults enforced in code |
|
|
254
255
|
| `scripts/ledger.mjs` | `npx job-forge ledger:status` / `ledger:has` / `ledger:rebuild` | Deterministic `@agent-pattern-labs/iso-ledger` state over tracker, TSV, and pipeline files |
|
|
255
256
|
| `scripts/capabilities.mjs` | `npx job-forge capabilities:check` / `capabilities:explain` | Deterministic `@agent-pattern-labs/iso-capabilities` role boundary checks for tools, MCPs, commands, filesystem, and network access |
|
|
256
257
|
| `scripts/cache.mjs` | `npx job-forge cache:has` / `cache:get` / `cache:put` | Deterministic `@agent-pattern-labs/iso-cache` JD and artifact reuse keyed by stable job/url inputs |
|
package/docs/SETUP.md
CHANGED
|
@@ -216,6 +216,8 @@ Use it to identify which sessions or models are consuming the most tokens. The `
|
|
|
216
216
|
**PDF generation fails**
|
|
217
217
|
The scaffolded `opencode.json` already registers Geometra MCP; if it's not running, check `opencode mcp list` and verify the scaffolded config under the `mcp.geometra` key — its `command` MUST be `["npx", "--no-install", "job-forge", "mcp:geometra"]`, `enabled: true`, and its `environment` should include `GEOMETRA_STEALTH=1` (or equivalently `GEOMETRA_BROWSER=stealth`) so proxy-backed portal sessions default to CloakBrowser's patched Chromium. `job-forge mcp:geometra` resolves Geometra in this order: `JOB_FORGE_GEOMETRA_MCP_PATH`, then a consumer-project override from `package.json -> jobForge.geometraMcpPath`, then `opencode.json -> mcp.geometra.environment.JOB_FORGE_GEOMETRA_MCP_PATH`, then a sibling `../geometra/mcp/dist/index.js` checkout for local JobForge development, and finally the pinned npm package. Geometra manages Chromium via its built-in proxy. JobForge still passes `headless: true` and `stealth: true` for portal sessions explicitly; the env block keeps the default aligned for auto-spawned sessions and local debugging. For standalone CLI usage (outside opencode), `generate-pdf.mjs` also works with standalone Playwright/Chromium — install with `npx playwright install chromium`.
|
|
218
218
|
|
|
219
|
+
`job-forge mcp:geometra` writes MCP launcher diagnostics to `.jobforge-mcp/geometra-mcp.jsonl`. If the server silently vanishes with empty stderr, run `tail -40 .jobforge-mcp/geometra-mcp.jsonl`. A final `signal_received` event means the host sent a catchable signal, a final `child_exit` means Geometra exited, and an old final `heartbeat` with no exit/signal usually means SIGKILL or external process death. Set `JOB_FORGE_GEOMETRA_MCP_LOG_PATH` to move the log, `JOB_FORGE_GEOMETRA_MCP_LOG=0` to disable it, or `JOB_FORGE_GEOMETRA_MCP_HEARTBEAT_MS` to tune the heartbeat interval.
|
|
220
|
+
|
|
219
221
|
For consumer projects that should always use a local Geometra checkout across Opencode, Codex, Cursor, and Claude, prefer a local `package.json` override instead of editing symlinked MCP configs:
|
|
220
222
|
|
|
221
223
|
```json
|
package/iso/instructions.md
CHANGED
|
@@ -31,6 +31,9 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
31
31
|
- [H8] Never paste proxy values from `config/profile.yml` into `task` prompts, status text, or summaries. If a proxy is configured, tell the subagent exactly: "Proxy is configured; read `config/profile.yml` and pass its top-level `proxy:` object plus `headless: true` and `stealth: true` to every `geometra_connect` call and every Geometra auto-connect call that passes `pageUrl` or `url`." Do not transcribe `server`, `username`, `password`, or `bypass`, even if you just read them from disk.
|
|
32
32
|
why: a 2026-04-25 OpenCode trace showed raw proxy credentials copied into an apply subagent prompt; trace logs are local, but prompts must still avoid replicating secrets across subagent sessions. Geometra MCP opens visible Chromium unless `headless: true` is explicit, and Geometra MCP >=1.61.3 can launch CloakBrowser stealth Chromium via `stealth: true`; both flags belong with JobForge portal sessions instead of stock visible Playwright Chromium
|
|
33
33
|
|
|
34
|
+
- [H9] If Geometra MCP disappears, becomes unresponsive, or returns a cascade of `Not connected` after a live form-fill, inspect `.jobforge-mcp/geometra-mcp.jsonl` before guessing. Report the last `launcher_start`, `child_spawn`, `heartbeat`, `signal_received`, `child_stderr`, and `child_exit` events plus the timestamp gap from the last heartbeat. If the last event is an old heartbeat with no `signal_received` / `child_exit`, treat it as likely host SIGKILL or external process death.
|
|
35
|
+
why: OpenCode or the OS can kill the MCP server without stderr, crash logs, or core dumps. JobForge's MCP launcher writes durable lifecycle events outside MCP stdout, so silent disappearances still leave enough evidence to distinguish host kill, child crash, stderr failure, and wrapper health
|
|
36
|
+
|
|
34
37
|
## Defaults
|
|
35
38
|
|
|
36
39
|
- [D1] Delegate to a subagent (`task`) only when the work involves repeated tool-heavy steps that bloat the cache prefix: applying to N≥2 jobs, batch scans hitting ≥3 companies, or any "apply to… / process pipeline / batch evaluate" user phrasing. Single-offer evals, dev work, file edits, `tracker` mode, single-URL checks, and one-shot questions stay inline.
|
|
@@ -62,7 +65,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
62
65
|
1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
|
|
63
66
|
2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
|
|
64
67
|
3. Read the active mode file [D3]. Use local helpers when they can replace broad file reads, prose math, manual policy checks, or artifact reuse decisions [D8]. Decide inline vs delegated work [D1].
|
|
65
|
-
4. Prepare Geometra dispatches: cleanup [H3], local-helper prefilters when useful [D8], dedupe [H2], location filter [D5], file-backed preflight plan/check [D8], routing [D2], proxy/headless/stealth prompt hygiene [H8].
|
|
68
|
+
4. Prepare Geometra dispatches: cleanup [H3], local-helper prefilters when useful [D8], dedupe [H2], location filter [D5], file-backed preflight plan/check [D8], routing [D2], proxy/headless/stealth prompt hygiene [H8], MCP lifecycle log awareness [H9].
|
|
66
69
|
5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b], then settle the round with postflight status [D8].
|
|
67
70
|
6. Keep multi-job form-filling out of the orchestrator [H4].
|
|
68
71
|
7. Cross-check subagent facts against authoritative files [H7].
|
package/modes/apply.md
CHANGED
|
@@ -4,8 +4,8 @@ Live application assistant. Reads the active application form in Chrome (via Geo
|
|
|
4
4
|
|
|
5
5
|
## Hard limits
|
|
6
6
|
|
|
7
|
-
- [H1] Submit the form
|
|
8
|
-
why: Greenhouse-style forms regenerate internal field IDs after any DOM-mutating action (especially file uploads); multi-call sequences see stale IDs, enter a retry loop, and burn tens of thousands of tokens
|
|
7
|
+
- [H1] Submit the form with one stable `geometra_run_actions` action array that chains upload + fill + pick + submit. Set `softTimeoutMs: 45000`, `output: "final"`, and `includeSteps: false`; if Geometra returns `paused: true` with `resumeFromIndex`, immediately continue with the exact same `actions` array and that `resumeFromIndex`. Never split upload / fill / submit across separate direct tools or rebuilt action arrays.
|
|
8
|
+
why: Greenhouse-style forms regenerate internal field IDs after any DOM-mutating action (especially file uploads); multi-call sequences see stale IDs, enter a retry loop, and burn tens of thousands of tokens. The soft-timeout continuation keeps the MCP call under host timeout without changing the action array or restarting the application
|
|
9
9
|
|
|
10
10
|
- [H2] Never auto-retry a failed submit. On recovery failure, report the error to the orchestrator and stop. The orchestrator decides whether to re-dispatch.
|
|
11
11
|
why: duplicate applications are worse than a missed retry — ATS portals often accept a submit whose response was dropped mid-flight, so a retry double-submits. A human must decide.
|
|
@@ -64,7 +64,7 @@ Live application assistant. Reads the active application form in Chrome (via Geo
|
|
|
64
64
|
9. Route high-stakes applications through `@general-paid` [D8].
|
|
65
65
|
10. Extract form questions; classify each Section-G vs new.
|
|
66
66
|
11. Generate answers from Block B + Block F + Section G + JD.
|
|
67
|
-
12. Submit
|
|
67
|
+
12. Submit via one stable `run_actions` action array [H1] using labels [D6], `imeFriendly: true` [D4], `softTimeoutMs: 45000`, `output: "final"`, and `includeSteps: false`.
|
|
68
68
|
13. On session error, run the 4-step recovery; only one retry [H2].
|
|
69
69
|
14. On provider failure, stop and inspect telemetry before any retry [D9].
|
|
70
70
|
15. On OTP prompt, fetch the code from Gmail via `gmail_get_message`.
|
|
@@ -308,13 +308,16 @@ Notes:
|
|
|
308
308
|
|
|
309
309
|
When the candidate asks you to actually submit (or when running in auto-pipeline mode at score ≥ 3.0), follow these rules **strictly**. Greenhouse-style forms regenerate internal field IDs after any DOM-mutating action, especially file uploads. That breaks multi-call fill sequences and forces the model into a retry loop that burns tens of thousands of tokens.
|
|
310
310
|
|
|
311
|
-
### Use one `run_actions`
|
|
311
|
+
### Use one stable `run_actions` action array (Rule A — never split)
|
|
312
312
|
|
|
313
|
-
Do the entire submission
|
|
313
|
+
Do the entire submission with one stable `geometra_run_actions` `actions` array that chains all steps. Never split upload / fill / submit across separate direct tools, and never rebuild the action array between continuations.
|
|
314
314
|
|
|
315
315
|
```
|
|
316
316
|
geometra_run_actions({
|
|
317
317
|
sessionId: "...",
|
|
318
|
+
softTimeoutMs: 45000,
|
|
319
|
+
output: "final",
|
|
320
|
+
includeSteps: false,
|
|
318
321
|
actions: [
|
|
319
322
|
{ type: "upload_files", fieldLabel: "Resume/CV", paths: ["/abs/path/cv.pdf"] },
|
|
320
323
|
{ type: "fill_fields", imeFriendly: true,
|
|
@@ -326,6 +329,8 @@ geometra_run_actions({
|
|
|
326
329
|
})
|
|
327
330
|
```
|
|
328
331
|
|
|
332
|
+
If the response contains `paused: true`, `pauseReason: "soft-timeout"`, and `resumeFromIndex`, call `geometra_run_actions` again immediately with the same `sessionId`, the exact same `actions` array, `softTimeoutMs: 45000`, `output: "final"`, `includeSteps: false`, and `resumeFromIndex` set to the returned value. This is a continuation, not a retry, and it is the host-safe path for long Samsara/Greenhouse-style embeds. Stop only if the continued call fails, not merely because it paused.
|
|
333
|
+
|
|
329
334
|
**Always pass `imeFriendly: true` on `fill_fields` for Ashby** (and safe as a default everywhere). Ashby's React form swallows programmatic text input silently — visible value looks correct, `invalidCount` stays >0, and Submit fails with "field required" or "flagged as possible spam." `imeFriendly: true` fires proper composition events that clear React's internal validity state. Confirmed fix: Supabase #793 (2026-04-19). Zero cost on other portals; no reason to leave it off.
|
|
330
335
|
|
|
331
336
|
### Use `fieldLabel` over `fieldId` (Rule B)
|
|
@@ -356,6 +361,9 @@ Call 3: geometra_connect({
|
|
|
356
361
|
})
|
|
357
362
|
Call 4: geometra_run_actions({
|
|
358
363
|
sessionId: "<new sessionId from Call 3>",
|
|
364
|
+
softTimeoutMs: 45000,
|
|
365
|
+
output: "final",
|
|
366
|
+
includeSteps: false,
|
|
359
367
|
actions: [... the EXACT same actions array you used before ...]
|
|
360
368
|
})
|
|
361
369
|
```
|
|
@@ -365,7 +373,8 @@ Call 4: geometra_run_actions({
|
|
|
365
373
|
1. **Always run all 4 calls.** Do not skip Call 1 or Call 2 even if Call 1 shows an empty pool.
|
|
366
374
|
2. **Do not re-fetch the form schema.** Do not call `geometra_form_schema` between Call 3 and Call 4. Your labels haven't changed, so the same `actions` array still works.
|
|
367
375
|
3. **Do not edit the actions array.** Copy it verbatim from your first attempt. Do not re-pick fieldIds. Do not add or remove actions. Same array in, same array out.
|
|
368
|
-
4. **
|
|
376
|
+
4. **Soft-timeout pause is not failure.** If Call 4 returns `paused: true`, continue with the returned `resumeFromIndex` and the exact same action array. Do not count this as the one retry.
|
|
377
|
+
5. **Only ONE retry.** If Call 4 ALSO fails, STOP. Return this exact message to the orchestrator:
|
|
369
378
|
|
|
370
379
|
```
|
|
371
380
|
APPLY FAILED AFTER RECOVERY: <URL>
|
package/modes/auto-pipeline.md
CHANGED
|
@@ -9,7 +9,7 @@ Fetch the JD content once. If the input is a **URL** (not pasted JD text), fetch
|
|
|
9
9
|
**Pick exactly one method, in this priority order:**
|
|
10
10
|
|
|
11
11
|
1. **Greenhouse JSON API (first try, if the URL is Greenhouse-backed):** If the pipeline.md entry carries `| gh={slug}/{id}` OR the URL host matches `*.greenhouse.io` / a known Greenhouse customer front-end (`*.pinterestcareers.com`, `okta.com/company/careers/opportunity/*`, `samsara.com/company/careers/roles/*`, `zoominfo.com/careers?gh_jid=*`, `collibra.com/.../?gh_jid=*`, `careers.toasttab.com/jobs?gh_jid=*`, `careers.airbnb.com/positions/*?gh_jid=*`, `coinbase.com/careers/positions/*?gh_jid=*`, `instacart.careers/job/?gh_jid=*`), extract `slug` and `id` and WebFetch `https://boards-api.greenhouse.io/v1/boards/{slug}/jobs/{id}`. 200 + JSON with `content` is the authoritative JD. 404 = genuinely closed (mark CLOSED and stop). **OpenCode WebFetch compatibility:** do not pass `format: "json"`; omit `format` or use `format: "text"` and parse the returned JSON text. **If 200, STOP — do not fall back to Geometra or WebFetch of the front-end.** The API is faster, cheaper (no Geometra session), and never returns a bot-shell.
|
|
12
|
-
2. **Geometra
|
|
12
|
+
2. **Direct Geometra helper:** Most non-Greenhouse job portals (Lever, Ashby, Workday) are SPAs. Use `npx job-forge portal:snapshot --url "{url}" --json` to render and read the page model/snapshot. This helper enforces `headless: true`, `stealth: true`, and `isolated: true` in code, reads `config/profile.yml` proxy config, and closes Chromium before exit. **If this returns non-empty JD text, STOP — do not WebFetch the same URL.**
|
|
13
13
|
3. **WebFetch (only if Geometra is unavailable OR returned only a shell with no JD text):** For static pages (ZipRecruiter, WeLoveProduct, company career pages).
|
|
14
14
|
4. **WebSearch (only if methods 1–3 all failed):** Search for the role title + company on secondary portals that index the JD in static HTML.
|
|
15
15
|
|
|
@@ -38,7 +38,7 @@ Execute the full `pdf` pipeline (read `modes/pdf.md`).
|
|
|
38
38
|
|
|
39
39
|
Generate draft answers for the application form when the final score is >= 3.5. If the final score is >= 3.5 (per Canonical Scoring Model thresholds in `_shared.md`), generate draft answers for the application form:
|
|
40
40
|
|
|
41
|
-
1. **Extract form questions**:
|
|
41
|
+
1. **Extract form questions**: Prefer `npx job-forge portal:form-schema --url "{apply_url}" --json` to discover all form fields without leaving a browser open. Use Geometra MCP only when a live multi-step browser session already exists and must be reused. If questions cannot be extracted, use the generic questions.
|
|
42
42
|
2. **Generate answers** following the tone guidelines (see below).
|
|
43
43
|
3. **Save in the report** as a `## G) Draft Application Answers` section.
|
|
44
44
|
|
package/modes/pipeline.md
CHANGED
|
@@ -7,7 +7,7 @@ Processes accumulated job offer URLs from `data/pipeline.md`. The user adds URLs
|
|
|
7
7
|
1. **Read** `data/pipeline.md` → find `- [ ]` items in the "Pending" section
|
|
8
8
|
2. **For each pending URL**:
|
|
9
9
|
a. Calculate the next sequential `REPORT_NUM` by running `npx job-forge next-num` (scans `reports/`, day file `#` columns, and `batch/tracker-additions/` — do NOT derive from `reports/` alone)
|
|
10
|
-
b. **Extract JD** using
|
|
10
|
+
b. **Extract JD** using Greenhouse API → `npx job-forge portal:snapshot --url "$URL" --json` → Geometra MCP only if an interactive session is needed → WebFetch → WebSearch
|
|
11
11
|
c. If the URL is not accessible → mark as `- [!]` with a note and continue
|
|
12
12
|
d. **Run full auto-pipeline**: A-F Evaluation → Report .md → PDF (if score >= 3.0, per `_shared.md` thresholds) → Draft answers (if score >= 3.5) → Tracker
|
|
13
13
|
e. **Move from "Pending" to "Processed"**: `- [x] #NNN | URL | Company | Role | Score/5 | PDF ✅/❌`
|
|
@@ -34,9 +34,10 @@ Processes accumulated job offer URLs from `data/pipeline.md`. The user adds URLs
|
|
|
34
34
|
## Detect JD From URL
|
|
35
35
|
|
|
36
36
|
1. **Greenhouse JSON API (FIRST, when the entry has `| gh={slug}/{id}` OR the host looks Greenhouse-backed):** WebFetch `https://boards-api.greenhouse.io/v1/boards/{slug}/jobs/{id}`. 200 + JSON with `content` = LIVE, use it as the JD; 404 = genuinely CLOSED (mark `- [!]` and continue). **OpenCode WebFetch compatibility:** do not pass `format: "json"`; omit `format` or use `format: "text"` and parse the returned JSON text. Bot-hostile customer fronts (`pinterestcareers.com`, `okta.com`, `samsara.com`, `zoominfo.com`, `collibra.com`, `careers.toasttab.com`, `careers.airbnb.com`, `coinbase.com`, `instacart.careers`, `careers.toasttab.com`) MUST be verified via this API first — WebFetch/Geometra of those domains returns a shell or 403 and causes false CLOSED marks.
|
|
37
|
-
2. **Geometra
|
|
38
|
-
3. **
|
|
39
|
-
4. **
|
|
37
|
+
2. **Direct Geometra helper:** `npx job-forge portal:snapshot --url "{url}" --json`. Works with non-Greenhouse SPAs (Lever, Ashby, Workday), enforces `headless: true`, `stealth: true`, and `isolated: true` in code, reads `config/profile.yml` proxy config, and closes Chromium before exit.
|
|
38
|
+
3. **Geometra MCP (interactive fallback):** Use only when the one-shot helper is not enough and a live multi-step browser session is required.
|
|
39
|
+
4. **WebFetch (fallback):** For static pages or when Geometra is not available.
|
|
40
|
+
5. **WebSearch (last resort):** Search on secondary portals that index the JD.
|
|
40
41
|
|
|
41
42
|
**Special cases:**
|
|
42
43
|
- **LinkedIn**: May require login → mark `[!]` and ask the user to paste the text
|
|
@@ -68,6 +69,7 @@ Step 1 — Read data/pipeline.md; collect "- [ ]" URLs into `pending = [url_1,
|
|
|
68
69
|
Step 2 — Pre-flight cleanup (once, before loop):
|
|
69
70
|
geometra_list_sessions()
|
|
70
71
|
geometra_disconnect({ closeBrowser: true })
|
|
72
|
+
# portal:* helpers are direct-package one-shots and auto-close.
|
|
71
73
|
Step 3 — For round in ceil(N/2):
|
|
72
74
|
pair = pending[round*2 : round*2 + 2]
|
|
73
75
|
# ONE message, 1 or 2 task() calls. Never 3.
|
|
@@ -159,6 +159,8 @@ Subagents launched via the `task` tool start with a fresh context and cannot aut
|
|
|
159
159
|
|
|
160
160
|
**Fix in one sentence:** ALWAYS run `geometra_list_sessions` + `geometra_disconnect` BEFORE `geometra_connect`. Every time. No exceptions except the one explicit exception below.
|
|
161
161
|
|
|
162
|
+
If Geometra MCP itself disappears or becomes unresponsive, inspect `.jobforge-mcp/geometra-mcp.jsonl` before escalating. `signal_received` means the MCP host or parent process sent a catchable signal, `child_exit` means the Geometra child exited, `child_stderr` preserves stderr that may not show in the agent transcript, and a final stale `heartbeat` with no later event usually means SIGKILL / host reap / OS kill. Report the final event type and timestamp rather than saying only "MCP crashed."
|
|
163
|
+
|
|
162
164
|
---
|
|
163
165
|
|
|
164
166
|
#### Rule 1 — Orchestrator pre-dispatch cleanup (DO THIS EVERY TIME)
|
|
@@ -10,6 +10,7 @@ Prefer a local helper when the workflow needs:
|
|
|
10
10
|
- Machine-readable artifact validation.
|
|
11
11
|
- Context, capability, or migration policy.
|
|
12
12
|
- Dispatch planning or settlement.
|
|
13
|
+
- One-shot rendered browser snapshots or form schemas.
|
|
13
14
|
- Scoring, timing, priority, or lineage decisions.
|
|
14
15
|
- Safe export checks.
|
|
15
16
|
|
|
@@ -26,6 +27,7 @@ Do not paste whole helper outputs into prompts unless the downstream agent needs
|
|
|
26
27
|
| Artifact contracts | `templates/contracts.json` | `npx job-forge tracker-line ... --write`; `npx job-forge verify` |
|
|
27
28
|
| Role capability policy | `templates/capabilities.json` | `npx job-forge capabilities:*` |
|
|
28
29
|
| Context bundle policy | `templates/context.json` | `npx job-forge context:*` |
|
|
30
|
+
| Browser snapshots / form schemas | Direct `@geometra/mcp` session module | `npx job-forge portal:*` |
|
|
29
31
|
| JD/artifact reuse | `.jobforge-cache/` | `npx job-forge cache:*` |
|
|
30
32
|
| Artifact lookup | `.jobforge-index.json` from `templates/index.json` | `npx job-forge index:*` |
|
|
31
33
|
| Source-backed facts | `.jobforge-facts.json` from `templates/facts.json` | `npx job-forge facts:*` |
|
|
@@ -49,6 +51,7 @@ Do not paste whole helper outputs into prompts unless the downstream agent needs
|
|
|
49
51
|
- For generated reports or PDFs reused after input changes, run `lineage:check --artifact <file>` if lineage exists; after creating derived artifacts, record them with `lineage:record --artifact <file> --input <source>...`.
|
|
50
52
|
- Before exporting traces, prompts, reports, or fixtures outside the project, run `redact:scan`, `redact:apply`, or `redact:verify`.
|
|
51
53
|
- When diagnosing consumer harness drift, run `migrate:plan` or `migrate:check`; `job-forge sync` applies safe migrations automatically unless `JOB_FORGE_SKIP_MIGRATIONS=1` is set.
|
|
54
|
+
- When you only need a rendered page model, compact snapshot, or form schema from one URL, prefer `portal:snapshot` / `portal:form-schema` over Geometra MCP tool calls. Use MCP for interactive multi-step browser sessions.
|
|
52
55
|
|
|
53
56
|
## Enforcement
|
|
54
57
|
|
|
@@ -100,6 +100,10 @@ Apply these rules when deciding whether the proxy is worth waiting for:
|
|
|
100
100
|
|
|
101
101
|
The Geometra MCP partitions its reusable-proxy pool by proxy identity and browser flavor — proxy partitioning landed in `@geometra/mcp@1.59.0`, and stealth partitioning is available in `@geometra/mcp@1.61.3`. A direct session and a proxied session NEVER share a Chromium instance, and stock and stealth sessions do not pool together. Practical consequence: flipping `proxy:` on or off in `profile.yml` mid-session is safe — the next `geometra_connect` just opens a fresh Chromium in its own pool partition.
|
|
102
102
|
|
|
103
|
+
### Direct helper for one-shot reads
|
|
104
|
+
|
|
105
|
+
Use `npx job-forge portal:snapshot --url "{url}" --json` or `npx job-forge portal:form-schema --url "{url}" --json` when you only need a rendered page model, compact snapshot, or form schema. These commands import Geometra's session module directly instead of going through MCP, enforce `headless: true`, `stealth: true`, and `isolated: true`, pass the `config/profile.yml` proxy block if configured, and close Chromium before exit. Keep MCP for interactive multi-step browser automation where a live `sessionId` must be driven across actions.
|
|
106
|
+
|
|
103
107
|
### Troubleshooting
|
|
104
108
|
|
|
105
109
|
| Symptom | Diagnosis |
|
|
@@ -141,4 +145,20 @@ The Geometra MCP partitions its reusable-proxy pool by proxy identity and browse
|
|
|
141
145
|
|
|
142
146
|
`job-forge mcp:geometra` resolves Geometra in this order: `JOB_FORGE_GEOMETRA_MCP_PATH`, then `package.json -> jobForge.geometraMcpPath`, then `opencode.json -> mcp.geometra.environment.JOB_FORGE_GEOMETRA_MCP_PATH`, then a sibling local `../geometra/mcp/dist/index.js` checkout for maintainers working across both repos, then the pinned npm fallback.
|
|
143
147
|
|
|
148
|
+
JobForge's Geometra launcher writes a durable lifecycle log at `.jobforge-mcp/geometra-mcp.jsonl` in the consumer project. The log is JSONL and does not use stdout, so it does not interfere with the MCP stdio protocol. Expected events include `launcher_start`, `child_spawn`, periodic `heartbeat`, `child_stderr`, `signal_received`, `child_error`, and `child_exit`. Override the location with `JOB_FORGE_GEOMETRA_MCP_LOG_PATH`, disable it with `JOB_FORGE_GEOMETRA_MCP_LOG=0`, or tune the heartbeat with `JOB_FORGE_GEOMETRA_MCP_HEARTBEAT_MS`.
|
|
149
|
+
|
|
144
150
|
To check or modify MCP settings, edit `opencode.json` in the project root.
|
|
151
|
+
|
|
152
|
+
## Silent MCP Death Diagnostics
|
|
153
|
+
|
|
154
|
+
If Geometra MCP vanishes with no stderr, no crash log, and subsequent calls return `Not connected`, inspect the lifecycle log before making a claim:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
tail -40 .jobforge-mcp/geometra-mcp.jsonl
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- Last event is `signal_received`: the MCP host or parent process sent a catchable signal such as `SIGTERM`.
|
|
161
|
+
- Last event is `child_exit`: the Geometra child process exited and the log should show its code or signal.
|
|
162
|
+
- Last event is `child_stderr`: preserve the stderr text; it is the best upstream bug report payload.
|
|
163
|
+
- Last event is an old `heartbeat` with no later `signal_received` or `child_exit`: likely host `SIGKILL`, OS kill, or wrapper process death. No process can log after `SIGKILL`, so report the heartbeat timestamp and the missing exit event.
|
|
164
|
+
- No `launcher_start`: OpenCode never started JobForge's Geometra launcher, or it used a different MCP command/config.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-forge",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.44",
|
|
4
4
|
"description": "AI-powered job search pipeline built on opencode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
"tokens": "node scripts/token-usage-report.mjs",
|
|
19
19
|
"tokens:today": "node scripts/token-usage-report.mjs --days 1",
|
|
20
20
|
"tokens:log": "node scripts/token-usage-report.mjs --days 1 --append",
|
|
21
|
+
"portal:snapshot": "node bin/job-forge.mjs portal:snapshot",
|
|
22
|
+
"portal:form-schema": "node bin/job-forge.mjs portal:form-schema",
|
|
23
|
+
"portal:explain": "node bin/job-forge.mjs portal:explain",
|
|
21
24
|
"trace:list": "node bin/job-forge.mjs trace:list",
|
|
22
25
|
"trace:stats": "node bin/job-forge.mjs trace:stats",
|
|
23
26
|
"trace:show": "node bin/job-forge.mjs trace:show",
|
|
@@ -196,6 +199,7 @@
|
|
|
196
199
|
"@agent-pattern-labs/iso-score": "^0.1.1",
|
|
197
200
|
"@agent-pattern-labs/iso-timeline": "^0.1.1",
|
|
198
201
|
"@agent-pattern-labs/iso-trace": "^0.5.1",
|
|
202
|
+
"@geometra/mcp": "1.62.1",
|
|
199
203
|
"playwright": "^1.58.1"
|
|
200
204
|
},
|
|
201
205
|
"devDependencies": {
|
|
@@ -20,6 +20,7 @@ const groups = [
|
|
|
20
20
|
helper('trace', '@agent-pattern-labs/iso-trace', ['list', 'stats', 'show']),
|
|
21
21
|
helper('telemetry', '', ['list', 'status', 'show', 'watch']),
|
|
22
22
|
helper('guard', '@agent-pattern-labs/iso-guard', ['audit', 'explain'], { template: 'templates/guards/jobforge-baseline.yaml' }),
|
|
23
|
+
helper('portal', '@geometra/mcp', ['snapshot', 'form-schema', 'explain'], { migrated: true }),
|
|
23
24
|
helper('ledger', '@agent-pattern-labs/iso-ledger', ['status', 'rebuild', 'verify', 'has', 'query'], { artifacts: ['.jobforge-ledger/'] }),
|
|
24
25
|
helper('capabilities', '@agent-pattern-labs/iso-capabilities', ['list', 'explain', 'check', 'render'], { template: 'templates/capabilities.json', migrated: true }),
|
|
25
26
|
helper('context', '@agent-pattern-labs/iso-context', ['list', 'explain', 'plan', 'check', 'render'], { template: 'templates/context.json', migrated: true }),
|
|
@@ -22,12 +22,14 @@ const checks = [
|
|
|
22
22
|
["H6 requires merge and verify", () => every(files.instructions, ["batch/tracker-additions/*.tsv", "npx job-forge merge", "npx job-forge verify"])],
|
|
23
23
|
["H7 distrusts subagent prose", () => every(files.instructions, ["must originate from a file", "not from prior subagent prose"])],
|
|
24
24
|
["H8 keeps proxy secret and requires headless stealth", () => every(files.instructions, ["[H8]", "Do not transcribe `server`, `username`, `password`, or `bypass`", "`headless: true`", "`stealth: true`"])],
|
|
25
|
+
["H9 points to Geometra MCP lifecycle logs", () => every(files.instructions, ["[H9]", ".jobforge-mcp/geometra-mcp.jsonl", "launcher_start", "heartbeat", "child_exit"])],
|
|
25
26
|
["OpenCode addendum exists for task semantics", () => every(files.instructionsOpencode, ["OpenCode", "`task`", "launch acknowledgement", "Do not use `task` to poll status"])],
|
|
26
27
|
["root points to consolidated helper reference", () => every(files.instructions, ["[D8]", "modes/reference-local-helpers.md", "deterministic local helpers"])],
|
|
27
28
|
["helper reference covers score/timeline/prioritize/lineage", () => every(files.helpers, ["templates/score.json", "npx job-forge score:*", "templates/timeline.json", "npx job-forge timeline:*", "templates/prioritize.json", "npx job-forge prioritize:*", ".jobforge-lineage.json", "npx job-forge lineage:*"])],
|
|
28
29
|
["root helper defaults are consolidated", () => !/\[D(?:9|1\d|2[0-9])\]/.test(files.instructions)],
|
|
29
30
|
["shared prompt points to on-demand references", () => every(files.instructions, ["modes/{mode}.md", "modes/reference-setup.md", "modes/reference-portals.md", "modes/reference-geometra.md"])],
|
|
30
31
|
["apply mode requires headless stealth Geometra", () => every(files.apply, ["`headless: true`", "`stealth: true`", "`isolated: true`", "every Geometra auto-connect call"])],
|
|
32
|
+
["apply mode uses host-safe run_actions continuations", () => every(files.apply, ["softTimeoutMs: 45000", "resumeFromIndex", "pauseReason: \"soft-timeout\"", "This is a continuation, not a retry"])],
|
|
31
33
|
["apply mode owns high-stakes upgrade", () => every(files.apply, ["[D8]", "@general-paid", "4.0/5", "high-stakes"])],
|
|
32
34
|
["apply mode blocks provider auto-downgrade", () => every(files.apply, ["[D9]", "do not auto-downgrade", "inspect telemetry before retrying"])],
|
|
33
35
|
["models policy pins OpenCode to DeepSeek V4 Flash", () => /extends:\s*standard/.test(files.models) && count(files.models, "opencode-go/deepseek-v4-flash") >= 4],
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
+
import { PROJECT_DIR } from '../tracker-lib.mjs';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_WIDTH = 1024;
|
|
10
|
+
const DEFAULT_HEIGHT = 768;
|
|
11
|
+
const DEFAULT_SLOW_MO = 350;
|
|
12
|
+
const DEFAULT_MAX_NODES = 120;
|
|
13
|
+
|
|
14
|
+
const USAGE = `job-forge portal - deterministic direct-Geometra browser helpers
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
job-forge portal:snapshot --url <url> [--json] [--forms] [--max-nodes N]
|
|
18
|
+
job-forge portal:form-schema --url <url> [--json] [--include-options]
|
|
19
|
+
job-forge portal:explain [--json]
|
|
20
|
+
|
|
21
|
+
Defaults are enforced in code for every browser launch:
|
|
22
|
+
isolated: true
|
|
23
|
+
headless: true
|
|
24
|
+
stealth: true
|
|
25
|
+
slowMo: 350
|
|
26
|
+
|
|
27
|
+
The helper imports Geometra's session module directly. It does not call the
|
|
28
|
+
MCP tool protocol, does not leave a reusable browser pool behind, and closes
|
|
29
|
+
its isolated Chromium before exit. If config/profile.yml contains a top-level
|
|
30
|
+
proxy: block with server/username/password/bypass, it is threaded into the
|
|
31
|
+
browser unless --no-profile-proxy is passed.`;
|
|
32
|
+
|
|
33
|
+
const [cmd = 'help', ...rawArgs] = process.argv.slice(2);
|
|
34
|
+
const opts = parseArgs(rawArgs);
|
|
35
|
+
|
|
36
|
+
if (opts.help || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
37
|
+
console.log(USAGE);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
if (cmd === 'snapshot') {
|
|
43
|
+
await snapshot(opts);
|
|
44
|
+
} else if (cmd === 'form-schema') {
|
|
45
|
+
await formSchema(opts);
|
|
46
|
+
} else if (cmd === 'explain') {
|
|
47
|
+
await explain(opts);
|
|
48
|
+
} else {
|
|
49
|
+
console.error(`unknown portal command "${cmd}"\n`);
|
|
50
|
+
console.error(USAGE);
|
|
51
|
+
process.exit(2);
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function snapshot(opts) {
|
|
59
|
+
if (!opts.url) throw new Error('portal:snapshot requires --url <url>');
|
|
60
|
+
const geometra = await loadGeometraSessionModule();
|
|
61
|
+
const proxy = opts.profileProxy ? readProfileProxy(PROJECT_DIR) : null;
|
|
62
|
+
const session = await connect(geometra, opts, proxy);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const root = buildRoot(geometra, session);
|
|
66
|
+
const pageModel = geometra.buildPageModel(root, {
|
|
67
|
+
maxPrimaryActions: opts.maxPrimaryActions,
|
|
68
|
+
maxSectionsPerKind: opts.maxSectionsPerKind,
|
|
69
|
+
});
|
|
70
|
+
const compact = geometra.buildCompactUiIndex(root, {
|
|
71
|
+
maxNodes: opts.maxNodes,
|
|
72
|
+
viewportWidth: opts.width,
|
|
73
|
+
viewportHeight: opts.height,
|
|
74
|
+
});
|
|
75
|
+
const result = {
|
|
76
|
+
url: opts.url,
|
|
77
|
+
session: connectionSummary(session, proxy),
|
|
78
|
+
defaults: launchDefaults(opts, proxy),
|
|
79
|
+
pageModel,
|
|
80
|
+
compact,
|
|
81
|
+
...(opts.forms ? { forms: geometra.buildFormSchemas(root, formOptions(opts)) } : {}),
|
|
82
|
+
};
|
|
83
|
+
output(result, opts, () => {
|
|
84
|
+
console.log(`url: ${opts.url}`);
|
|
85
|
+
console.log(`session: ${session.id}`);
|
|
86
|
+
console.log(`defaults: isolated=true headless=true stealth=true slowMo=${opts.slowMo}`);
|
|
87
|
+
if (proxy) console.log(`proxy: ${redactProxy(proxy)}`);
|
|
88
|
+
console.log(geometra.summarizePageModel(pageModel, 12));
|
|
89
|
+
console.log(geometra.summarizeCompactIndex(compact.nodes, 24));
|
|
90
|
+
if (opts.forms) {
|
|
91
|
+
console.log(`forms: ${result.forms.length}`);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
} finally {
|
|
95
|
+
geometra.disconnect({ sessionId: session.id, closeProxy: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function formSchema(opts) {
|
|
100
|
+
if (!opts.url) throw new Error('portal:form-schema requires --url <url>');
|
|
101
|
+
const geometra = await loadGeometraSessionModule();
|
|
102
|
+
const proxy = opts.profileProxy ? readProfileProxy(PROJECT_DIR) : null;
|
|
103
|
+
const session = await connect(geometra, opts, proxy);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const root = buildRoot(geometra, session);
|
|
107
|
+
const forms = geometra.buildFormSchemas(root, formOptions(opts));
|
|
108
|
+
const result = {
|
|
109
|
+
url: opts.url,
|
|
110
|
+
session: connectionSummary(session, proxy),
|
|
111
|
+
defaults: launchDefaults(opts, proxy),
|
|
112
|
+
forms,
|
|
113
|
+
};
|
|
114
|
+
output(result, opts, () => {
|
|
115
|
+
console.log(`url: ${opts.url}`);
|
|
116
|
+
console.log(`session: ${session.id}`);
|
|
117
|
+
console.log(`defaults: isolated=true headless=true stealth=true slowMo=${opts.slowMo}`);
|
|
118
|
+
if (proxy) console.log(`proxy: ${redactProxy(proxy)}`);
|
|
119
|
+
for (const form of forms) {
|
|
120
|
+
const name = form.name ? ` "${form.name}"` : '';
|
|
121
|
+
console.log(`${form.formId}${name}: ${form.fieldCount} fields, ${form.requiredCount} required, ${form.invalidCount} invalid`);
|
|
122
|
+
for (const field of form.fields.slice(0, opts.maxFields)) {
|
|
123
|
+
const required = field.required ? ' required' : '';
|
|
124
|
+
const invalid = field.invalid ? ' invalid' : '';
|
|
125
|
+
const label = field.label || field.name || field.id;
|
|
126
|
+
console.log(` - ${field.kind}: ${label}${required}${invalid}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
} finally {
|
|
131
|
+
geometra.disconnect({ sessionId: session.id, closeProxy: true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function explain(opts) {
|
|
136
|
+
const moduleTarget = resolveGeometraSessionModule();
|
|
137
|
+
const proxy = opts.profileProxy ? readProfileProxy(PROJECT_DIR) : null;
|
|
138
|
+
const result = {
|
|
139
|
+
projectDir: PROJECT_DIR,
|
|
140
|
+
module: moduleTarget,
|
|
141
|
+
defaults: launchDefaults(opts, proxy),
|
|
142
|
+
profileProxy: proxy ? redactProxy(proxy) : null,
|
|
143
|
+
};
|
|
144
|
+
output(result, opts, () => {
|
|
145
|
+
console.log(`project: ${PROJECT_DIR}`);
|
|
146
|
+
console.log(`module: ${moduleTarget.source} ${moduleTarget.path}`);
|
|
147
|
+
console.log(`defaults: isolated=true headless=true stealth=true slowMo=${opts.slowMo}`);
|
|
148
|
+
console.log(`profile proxy: ${proxy ? redactProxy(proxy) : 'none'}`);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function connect(geometra, opts, proxy) {
|
|
153
|
+
return await geometra.connectThroughProxy({
|
|
154
|
+
pageUrl: opts.url,
|
|
155
|
+
isolated: true,
|
|
156
|
+
headless: true,
|
|
157
|
+
stealth: true,
|
|
158
|
+
slowMo: opts.slowMo,
|
|
159
|
+
width: opts.width,
|
|
160
|
+
height: opts.height,
|
|
161
|
+
awaitInitialFrame: true,
|
|
162
|
+
eagerInitialExtract: true,
|
|
163
|
+
...(proxy ? { proxy } : {}),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildRoot(geometra, session) {
|
|
168
|
+
if (!session.tree || !session.layout) {
|
|
169
|
+
throw new Error(`Geometra session ${session.id} did not return an accessibility tree`);
|
|
170
|
+
}
|
|
171
|
+
return geometra.buildA11yTree(session.tree, session.layout);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function loadGeometraSessionModule() {
|
|
175
|
+
const target = resolveGeometraSessionModule();
|
|
176
|
+
try {
|
|
177
|
+
return await import(pathToFileURL(target.path).href);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
180
|
+
throw new Error(`Failed to load Geometra session module from ${target.path}: ${detail}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveGeometraSessionModule() {
|
|
185
|
+
const explicit = normalizeEnv(process.env.JOB_FORGE_GEOMETRA_SESSION_MODULE);
|
|
186
|
+
if (explicit) return existingModulePath('env', resolve(explicit));
|
|
187
|
+
|
|
188
|
+
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
189
|
+
const siblingPath = resolve(scriptDir, '../../geometra/mcp/dist/session.js');
|
|
190
|
+
if (existsSync(siblingPath)) return { source: 'sibling-repo', path: siblingPath };
|
|
191
|
+
|
|
192
|
+
const require = createRequire(import.meta.url);
|
|
193
|
+
try {
|
|
194
|
+
return {
|
|
195
|
+
source: 'package',
|
|
196
|
+
path: require.resolve('@geometra/mcp/dist/session.js'),
|
|
197
|
+
};
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
200
|
+
throw new Error(`Could not resolve @geometra/mcp/dist/session.js. Install dependencies with npm install. Resolution error: ${detail}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function existingModulePath(source, path) {
|
|
205
|
+
if (!existsSync(path)) throw new Error(`${source} Geometra session module not found: ${path}`);
|
|
206
|
+
return { source, path };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseArgs(args) {
|
|
210
|
+
const opts = {
|
|
211
|
+
help: false,
|
|
212
|
+
json: false,
|
|
213
|
+
forms: false,
|
|
214
|
+
includeOptions: false,
|
|
215
|
+
profileProxy: true,
|
|
216
|
+
width: DEFAULT_WIDTH,
|
|
217
|
+
height: DEFAULT_HEIGHT,
|
|
218
|
+
slowMo: DEFAULT_SLOW_MO,
|
|
219
|
+
maxNodes: DEFAULT_MAX_NODES,
|
|
220
|
+
maxFields: 80,
|
|
221
|
+
maxPrimaryActions: 6,
|
|
222
|
+
maxSectionsPerKind: 8,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
for (let i = 0; i < args.length; i++) {
|
|
226
|
+
const arg = args[i];
|
|
227
|
+
if (arg === '--url' || arg === '-u') {
|
|
228
|
+
opts.url = valueAfter(args, ++i, arg);
|
|
229
|
+
} else if (arg.startsWith('--url=')) {
|
|
230
|
+
opts.url = arg.slice('--url='.length);
|
|
231
|
+
} else if (arg === '--json') {
|
|
232
|
+
opts.json = true;
|
|
233
|
+
} else if (arg === '--forms') {
|
|
234
|
+
opts.forms = true;
|
|
235
|
+
} else if (arg === '--include-options') {
|
|
236
|
+
opts.includeOptions = true;
|
|
237
|
+
} else if (arg === '--no-profile-proxy') {
|
|
238
|
+
opts.profileProxy = false;
|
|
239
|
+
} else if (arg === '--width') {
|
|
240
|
+
opts.width = parsePositiveInt(valueAfter(args, ++i, arg), arg);
|
|
241
|
+
} else if (arg.startsWith('--width=')) {
|
|
242
|
+
opts.width = parsePositiveInt(arg.slice('--width='.length), '--width');
|
|
243
|
+
} else if (arg === '--height') {
|
|
244
|
+
opts.height = parsePositiveInt(valueAfter(args, ++i, arg), arg);
|
|
245
|
+
} else if (arg.startsWith('--height=')) {
|
|
246
|
+
opts.height = parsePositiveInt(arg.slice('--height='.length), '--height');
|
|
247
|
+
} else if (arg === '--slow-mo') {
|
|
248
|
+
opts.slowMo = parseNonNegativeInt(valueAfter(args, ++i, arg), arg);
|
|
249
|
+
} else if (arg.startsWith('--slow-mo=')) {
|
|
250
|
+
opts.slowMo = parseNonNegativeInt(arg.slice('--slow-mo='.length), '--slow-mo');
|
|
251
|
+
} else if (arg === '--max-nodes') {
|
|
252
|
+
opts.maxNodes = parsePositiveInt(valueAfter(args, ++i, arg), arg);
|
|
253
|
+
} else if (arg.startsWith('--max-nodes=')) {
|
|
254
|
+
opts.maxNodes = parsePositiveInt(arg.slice('--max-nodes='.length), '--max-nodes');
|
|
255
|
+
} else if (arg === '--max-fields') {
|
|
256
|
+
opts.maxFields = parsePositiveInt(valueAfter(args, ++i, arg), arg);
|
|
257
|
+
} else if (arg.startsWith('--max-fields=')) {
|
|
258
|
+
opts.maxFields = parsePositiveInt(arg.slice('--max-fields='.length), '--max-fields');
|
|
259
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
260
|
+
opts.help = true;
|
|
261
|
+
} else {
|
|
262
|
+
throw new Error(`unknown flag "${arg}"`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return opts;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function formOptions(opts) {
|
|
270
|
+
return {
|
|
271
|
+
includeOptions: opts.includeOptions,
|
|
272
|
+
maxFields: opts.maxFields,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function readProfileProxy(projectDir) {
|
|
277
|
+
const profilePath = join(projectDir, 'config', 'profile.yml');
|
|
278
|
+
if (!existsSync(profilePath)) return null;
|
|
279
|
+
const proxy = parseTopLevelProxy(readFileSync(profilePath, 'utf8'));
|
|
280
|
+
return proxy?.server ? proxy : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function parseTopLevelProxy(source) {
|
|
284
|
+
const lines = source.split(/\r?\n/);
|
|
285
|
+
const start = lines.findIndex((line) => /^proxy:\s*(?:#.*)?$/.test(line));
|
|
286
|
+
if (start === -1) return null;
|
|
287
|
+
const proxy = {};
|
|
288
|
+
for (const line of lines.slice(start + 1)) {
|
|
289
|
+
if (/^\S/.test(line) && line.trim() !== '') break;
|
|
290
|
+
const match = line.match(/^\s+([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$/);
|
|
291
|
+
if (!match) continue;
|
|
292
|
+
const key = match[1];
|
|
293
|
+
if (!['server', 'username', 'password', 'bypass'].includes(key)) continue;
|
|
294
|
+
const value = parseYamlScalar(match[2]);
|
|
295
|
+
if (value !== '') proxy[key] = value;
|
|
296
|
+
}
|
|
297
|
+
return Object.keys(proxy).length > 0 ? proxy : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function parseYamlScalar(raw) {
|
|
301
|
+
const withoutComment = raw.replace(/\s+#.*$/, '').trim();
|
|
302
|
+
if ((withoutComment.startsWith('"') && withoutComment.endsWith('"')) ||
|
|
303
|
+
(withoutComment.startsWith("'") && withoutComment.endsWith("'"))) {
|
|
304
|
+
return withoutComment.slice(1, -1);
|
|
305
|
+
}
|
|
306
|
+
return withoutComment;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function output(result, opts, textPrinter) {
|
|
310
|
+
if (opts.json) {
|
|
311
|
+
console.log(JSON.stringify(result, null, 2));
|
|
312
|
+
} else {
|
|
313
|
+
textPrinter();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function launchDefaults(opts, proxy) {
|
|
318
|
+
return {
|
|
319
|
+
isolated: true,
|
|
320
|
+
headless: true,
|
|
321
|
+
stealth: true,
|
|
322
|
+
slowMo: opts.slowMo,
|
|
323
|
+
width: opts.width,
|
|
324
|
+
height: opts.height,
|
|
325
|
+
profileProxy: Boolean(proxy),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function connectionSummary(session, proxy) {
|
|
330
|
+
return {
|
|
331
|
+
id: session.id,
|
|
332
|
+
url: session.url,
|
|
333
|
+
proxy: Boolean(proxy),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function redactProxy(proxy) {
|
|
338
|
+
try {
|
|
339
|
+
const url = new URL(proxy.server);
|
|
340
|
+
const auth = proxy.username || proxy.password || url.username || url.password ? ' auth=present' : '';
|
|
341
|
+
return `${url.protocol}//${url.host}${auth}${proxy.bypass ? ' bypass=present' : ''}`;
|
|
342
|
+
} catch {
|
|
343
|
+
return 'configured';
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function normalizeEnv(value) {
|
|
348
|
+
if (typeof value !== 'string') return null;
|
|
349
|
+
const trimmed = value.trim();
|
|
350
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function valueAfter(args, index, flag) {
|
|
354
|
+
const value = args[index];
|
|
355
|
+
if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
|
|
356
|
+
return value;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function parsePositiveInt(value, flag) {
|
|
360
|
+
const parsed = Number.parseInt(value, 10);
|
|
361
|
+
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${flag} must be a positive integer`);
|
|
362
|
+
return parsed;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function parseNonNegativeInt(value, flag) {
|
|
366
|
+
const parsed = Number.parseInt(value, 10);
|
|
367
|
+
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${flag} must be a non-negative integer`);
|
|
368
|
+
return parsed;
|
|
369
|
+
}
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
"context:plan": "job-forge context:plan",
|
|
20
20
|
"context:check": "job-forge context:check",
|
|
21
21
|
"context:render": "job-forge context:render",
|
|
22
|
+
"portal:snapshot": "job-forge portal:snapshot",
|
|
23
|
+
"portal:form-schema": "job-forge portal:form-schema",
|
|
24
|
+
"portal:explain": "job-forge portal:explain",
|
|
22
25
|
"cache:key": "job-forge cache:key",
|
|
23
26
|
"cache:has": "job-forge cache:has",
|
|
24
27
|
"cache:get": "job-forge cache:get",
|
|
@@ -109,6 +112,7 @@
|
|
|
109
112
|
".jobforge-prioritize.json",
|
|
110
113
|
".jobforge-prioritize-items.json",
|
|
111
114
|
".jobforge-lineage.json",
|
|
115
|
+
".jobforge-mcp/",
|
|
112
116
|
".jobforge-redacted/",
|
|
113
117
|
"data/timeline-events.jsonl",
|
|
114
118
|
"batch/preflight-candidates.json",
|