job-forge 2.14.14 → 2.14.15

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
@@ -76,7 +76,7 @@ JobForge turns opencode into a full job search command center. Instead of manual
76
76
  | **Durable Batch Orchestration** | `batch-runner.sh` uses `@razroo/iso-orchestrator` for resumable bundle execution, bounded fan-out, mutexed state writes, and workflow records in `.jobforge-runs/`. |
77
77
  | **Pipeline Integrity** | Automated merge, dedup, status normalization, health checks |
78
78
  | **Cost-Aware Agent Routing** | Three subagents (`@general-free`, `@general-paid`, `@glm-minimal`) with per-task tool surfaces. On OpenCode, JobForge pins all tiers to `opencode-go/deepseek-v4-flash` so application runs avoid overloaded free-model pools. See [Subagent Routing in AGENTS.md](AGENTS.md) for the task-to-agent mapping. |
79
- | **Trace + Telemetry** | `job-forge trace:*` exposes local OpenCode transcripts, and `job-forge telemetry:*` summarizes runs, child outcomes, provider errors, and pending tracker TSVs. |
79
+ | **Trace + Telemetry + Guard** | `job-forge trace:*` exposes local OpenCode transcripts, `job-forge telemetry:*` summarizes runs, and `job-forge guard:*` audits deterministic JobForge policy rules with `@razroo/iso-guard`. |
80
80
  | **Token Cost Visibility** | `job-forge tokens --days 1` for per-session breakdown; `job-forge session-report --since-minutes 60 --log` to flag sessions over budget and append history to `data/token-usage.tsv`. Auto-logged after every batch run. |
81
81
 
82
82
  ## Usage
@@ -117,6 +117,8 @@ const consumerPkg = {
117
117
  'telemetry:status': 'job-forge telemetry:status',
118
118
  'telemetry:show': 'job-forge telemetry:show',
119
119
  'telemetry:watch': 'job-forge telemetry:watch',
120
+ 'guard:audit': 'job-forge guard:audit',
121
+ 'guard:explain': 'job-forge guard:explain',
120
122
  // One command to pull the latest harness and any locally-pinned MCP
121
123
  // packages. npm update is a no-op on packages not in package.json, so
122
124
  // listing @razroo/gmail-mcp + @geometra/mcp is safe for consumers that
package/bin/job-forge.mjs CHANGED
@@ -19,6 +19,7 @@
19
19
  * tokens Run scripts/token-usage-report.mjs
20
20
  * trace:* Inspect local agent transcripts via iso-trace
21
21
  * telemetry:* Summarize JobForge pipeline status from traces + tracker files
22
+ * guard:* Audit JobForge trace policy with iso-guard
22
23
  * sync Re-run the harness symlink sync (bin/sync.mjs)
23
24
  * help, --help Show this message
24
25
  */
@@ -68,6 +69,11 @@ const telemetryAliases = {
68
69
  'telemetry:watch': 'watch',
69
70
  };
70
71
 
72
+ const guardAliases = {
73
+ 'guard:audit': 'audit',
74
+ 'guard:explain': 'explain',
75
+ };
76
+
71
77
  const [, , cmd, ...rest] = process.argv;
72
78
 
73
79
  function printHelp() {
@@ -92,6 +98,8 @@ Commands:
92
98
  telemetry:status Show latest JobForge run + pending tracker state
93
99
  telemetry:show ID Show one run with child sessions, provider errors, next actions
94
100
  telemetry:watch Watch latest run status
101
+ guard:audit Audit latest/local trace policy with iso-guard
102
+ guard:explain Show the active iso-guard policy
95
103
  sync Re-create harness symlinks in the current project
96
104
 
97
105
  Deterministic helpers (prefer these over LLM-derived values):
@@ -118,6 +126,8 @@ Pass --help after a command to see its own flags, e.g.:
118
126
  job-forge trace:show ses_...
119
127
  job-forge telemetry:status
120
128
  job-forge telemetry:show ses_...
129
+ job-forge guard:audit
130
+ job-forge guard:explain
121
131
 
122
132
  Project directory resolves to $JOB_FORGE_PROJECT or cwd.`);
123
133
  }
@@ -157,6 +167,21 @@ if (cmd === 'telemetry' || telemetryAliases[cmd]) {
157
167
  process.exit(result.status ?? 1);
158
168
  }
159
169
 
170
+ if (cmd === 'guard' || guardAliases[cmd]) {
171
+ const guardArgs = cmd === 'guard'
172
+ ? (rest.length === 0 ? ['help'] : rest)
173
+ : [guardAliases[cmd], ...rest];
174
+
175
+ const scriptPath = join(PKG_ROOT, 'scripts/guard.mjs');
176
+ const result = spawnSync(process.execPath, [scriptPath, ...guardArgs], {
177
+ stdio: 'inherit',
178
+ cwd: PROJECT_DIR,
179
+ env: process.env,
180
+ });
181
+
182
+ process.exit(result.status ?? 1);
183
+ }
184
+
160
185
  const rel = commands[cmd];
161
186
  if (!rel) {
162
187
  console.error(`Unknown command: ${cmd}\n`);
@@ -209,6 +209,7 @@ Scripts maintain data consistency. In a consumer project they're invoked via the
209
209
  | `scripts/token-usage-report.mjs` | `npx job-forge tokens` | Per-session opencode token/cost report from the SQLite DB |
210
210
  | `scripts/trace.mjs` | `npx job-forge trace:list` / `trace:stats` / `trace:show` | Local transcript observability via `@razroo/iso-trace`; common commands default to OpenCode sessions for the consumer project |
211
211
  | `scripts/telemetry.mjs` | `npx job-forge telemetry:status` / `telemetry:show` | JobForge operational telemetry derived from OpenCode traces plus tracker TSV state |
212
+ | `scripts/guard.mjs` | `npx job-forge guard:audit` / `guard:explain` | Deterministic `@razroo/iso-guard` policy audits over local OpenCode traces |
212
213
  | `tracker-lib.mjs` | _(library)_ | Shared helpers for reading/writing day-based tracker files — imported by merge/dedup/verify/normalize |
213
214
  | `bin/sync.mjs` | `npx job-forge sync` | Creates the harness symlinks in a consumer project (also runs as `postinstall`) |
214
215
  | `bin/create-job-forge.mjs` | `npx create-job-forge <dir>` | Scaffolds a new personal project |
@@ -125,6 +125,18 @@ npx job-forge telemetry:watch
125
125
 
126
126
  Telemetry is also local-only and passive. It reads OpenCode's SQLite DB and files under `batch/tracker-additions/`; agents do not need to remember to emit custom events.
127
127
 
128
+ ## JobForge guard audits
129
+
130
+ Guard audits run deterministic `@razroo/iso-guard` policies over the same local OpenCode traces. The default policy lives at `templates/guards/jobforge-baseline.yaml` and checks rules that are reliable from transcript data, including max two task dispatches per assistant message, no task-status polling via `task`, no raw proxy configuration in task prompts, and no child session task recursion.
131
+
132
+ ```bash
133
+ npx job-forge guard:audit
134
+ npx job-forge guard:audit <session-id-or-prefix>
135
+ npx job-forge guard:explain
136
+ ```
137
+
138
+ Use `--policy <path>` to audit with a custom policy. This does not add prompt, token, or MCP overhead; JobForge converts local trace rows into guard events inside the CLI process.
139
+
128
140
  **Where Claude Code writes JSONL:** `~/.claude/projects/<encoded-cwd>/*.jsonl`.
129
141
 
130
142
  **Direct CLI fallback:** `npx -y @razroo/iso-trace@latest stats --source "$HOME/.claude/projects/<encoded-dir>/<session>.jsonl"`
package/docs/README.md CHANGED
@@ -31,7 +31,7 @@ The harness exposes a single CLI (`job-forge`) installed as a `bin` entry. In a
31
31
 
32
32
  | What you need | Where to read |
33
33
  |---------------|---------------|
34
- | Full command list (`verify`, `merge`, `dedup`, `normalize`, `pdf`, `sync-check`, `tokens`, `sync`). | [SETUP.md — Tracker and scripts (terminal)](SETUP.md#tracker-and-scripts-terminal). |
34
+ | Full command list (`verify`, `merge`, `dedup`, `normalize`, `pdf`, `sync-check`, `tokens`, `trace`, `telemetry`, `guard`, `sync`). | [SETUP.md — Tracker and scripts (terminal)](SETUP.md#tracker-and-scripts-terminal). |
35
35
  | What each harness `.mjs` script does. | [ARCHITECTURE.md — Pipeline integrity](ARCHITECTURE.md#pipeline-integrity) and the scripts table underneath. |
36
36
  | Batch runner, TSV layout, and `batch/tracker-additions/` merge flow. | [batch/README.md](../batch/README.md). |
37
37
  | PR gate for harness contributions (`npm run verify` + `npm run build:dashboard`). | [CONTRIBUTING.md — Development](../CONTRIBUTING.md#development). |
package/docs/SETUP.md CHANGED
@@ -136,6 +136,8 @@ From your project root, these commands maintain the tracker and pipeline checks.
136
136
  | List recent JobForge runs with outcomes/issues | `npx job-forge telemetry:list` | `npm run telemetry:list` |
137
137
  | Show latest run status + pending TSVs | `npx job-forge telemetry:status` | `npm run telemetry:status` |
138
138
  | Show one JobForge run by session id/prefix | `npx job-forge telemetry:show <id>` | `npm run telemetry:show -- <id>` |
139
+ | Audit latest JobForge trace policy | `npx job-forge guard:audit` | `npm run guard:audit` |
140
+ | Show the active guard policy | `npx job-forge guard:explain` | `npm run guard:explain` |
139
141
  | Re-create harness symlinks | `npx job-forge sync` | `npm run sync` |
140
142
  | Build optional dashboard TUI (Go on `PATH`) | `(cd node_modules/job-forge/dashboard && go build .)` | `npm run build:dashboard` (harness repo only) |
141
143
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-forge",
3
- "version": "2.14.14",
3
+ "version": "2.14.15",
4
4
  "description": "AI-powered job search pipeline built on opencode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,8 @@
25
25
  "telemetry:status": "node bin/job-forge.mjs telemetry:status",
26
26
  "telemetry:show": "node bin/job-forge.mjs telemetry:show",
27
27
  "telemetry:watch": "node bin/job-forge.mjs telemetry:watch",
28
+ "guard:audit": "node bin/job-forge.mjs guard:audit",
29
+ "guard:explain": "node bin/job-forge.mjs guard:explain",
28
30
  "plan": "iso plan .",
29
31
  "lint:agentmd": "agentmd lint iso/instructions.md",
30
32
  "lint:modes": "isolint lint modes/",
@@ -89,6 +91,7 @@
89
91
  "node": ">=20.6.0"
90
92
  },
91
93
  "dependencies": {
94
+ "@razroo/iso-guard": "^0.1.0",
92
95
  "@razroo/iso-orchestrator": "^0.1.0",
93
96
  "@razroo/iso-trace": "^0.4.0",
94
97
  "playwright": "^1.58.1"
@@ -0,0 +1,404 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'child_process';
4
+ import { existsSync } from 'fs';
5
+ import { dirname, join, relative, resolve } from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import { audit, formatAuditResult, formatPolicyExplanation, loadPolicy, resultFails } from '@razroo/iso-guard';
8
+ import { defaultOpenCodeDbPath, findSessionById, parseSinceCutoff } from '@razroo/iso-trace';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const PKG_ROOT = resolve(__dirname, '..');
12
+ const PROJECT_DIR = process.env.JOB_FORGE_PROJECT || process.cwd();
13
+ const DEFAULT_SINCE = '24h';
14
+
15
+ const USAGE = `job-forge guard - deterministic JobForge policy audits over local OpenCode traces
16
+
17
+ Usage:
18
+ job-forge guard:audit [latest|<id-or-prefix>] [--since 24h] [--cwd <dir>] [--policy <path>] [--json] [--fail-on error|warn|off] [--root-only]
19
+ job-forge guard:explain [--policy <path>] [--json]
20
+
21
+ The default policy is templates/guards/jobforge-baseline.yaml. Guard audits are
22
+ local-only and passive: JobForge converts OpenCode SQLite rows into iso-guard
23
+ events and never asks agents or MCPs to emit extra telemetry.`;
24
+
25
+ const [cmd = 'help', ...args] = process.argv.slice(2);
26
+
27
+ function defaultPolicyPath() {
28
+ return join(PKG_ROOT, 'templates/guards/jobforge-baseline.yaml');
29
+ }
30
+
31
+ function parseArgs(rawArgs, { allowSession = false } = {}) {
32
+ const opts = {
33
+ since: DEFAULT_SINCE,
34
+ cwd: PROJECT_DIR,
35
+ policy: defaultPolicyPath(),
36
+ json: false,
37
+ failOn: 'error',
38
+ includeChildren: true,
39
+ };
40
+ const positional = [];
41
+
42
+ for (let i = 0; i < rawArgs.length; i++) {
43
+ const arg = rawArgs[i];
44
+ if (arg === '--since') {
45
+ opts.since = valueAfter(rawArgs, ++i, '--since');
46
+ } else if (arg.startsWith('--since=')) {
47
+ opts.since = arg.slice('--since='.length);
48
+ } else if (arg === '--cwd') {
49
+ opts.cwd = valueAfter(rawArgs, ++i, '--cwd');
50
+ } else if (arg.startsWith('--cwd=')) {
51
+ opts.cwd = arg.slice('--cwd='.length);
52
+ } else if (arg === '--policy') {
53
+ opts.policy = valueAfter(rawArgs, ++i, '--policy');
54
+ } else if (arg.startsWith('--policy=')) {
55
+ opts.policy = arg.slice('--policy='.length);
56
+ } else if (arg === '--fail-on') {
57
+ opts.failOn = valueAfter(rawArgs, ++i, '--fail-on');
58
+ } else if (arg.startsWith('--fail-on=')) {
59
+ opts.failOn = arg.slice('--fail-on='.length);
60
+ } else if (arg === '--json') {
61
+ opts.json = true;
62
+ } else if (arg === '--root-only') {
63
+ opts.includeChildren = false;
64
+ } else if (arg === '--help' || arg === '-h') {
65
+ opts.help = true;
66
+ } else if (arg.startsWith('--')) {
67
+ opts.error = `unknown flag "${arg}"`;
68
+ } else if (allowSession) {
69
+ positional.push(arg);
70
+ } else {
71
+ opts.error = `unexpected argument "${arg}"`;
72
+ }
73
+ }
74
+
75
+ opts.cwd = resolve(opts.cwd || PROJECT_DIR);
76
+ opts.policy = resolve(opts.policy || defaultPolicyPath());
77
+ if (!['error', 'warn', 'off'].includes(opts.failOn)) {
78
+ opts.error = '--fail-on must be one of: error, warn, off';
79
+ }
80
+ return { opts, positional };
81
+ }
82
+
83
+ function valueAfter(values, index, flag) {
84
+ const value = values[index];
85
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
86
+ return value;
87
+ }
88
+
89
+ function queryOpenCodeDb(dbPath, sql) {
90
+ const result = spawnSync('sqlite3', ['-json', dbPath, sql], {
91
+ encoding: 'utf8',
92
+ maxBuffer: 32 * 1024 * 1024,
93
+ });
94
+ if ((result.status ?? 0) !== 0) {
95
+ const detail = result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status ?? 1}`;
96
+ throw new Error(`job-forge guard: sqlite3 query failed: ${detail}`);
97
+ }
98
+ return JSON.parse(result.stdout || '[]');
99
+ }
100
+
101
+ function sqlString(value) {
102
+ return `'${String(value).replaceAll("'", "''")}'`;
103
+ }
104
+
105
+ function msToIso(ms) {
106
+ return new Date(Number(ms)).toISOString();
107
+ }
108
+
109
+ function discoverSessions(opts, { includeAllForShow = false } = {}) {
110
+ const dbPath = defaultOpenCodeDbPath();
111
+ if (!existsSync(dbPath)) return [];
112
+
113
+ const where = [
114
+ 's.time_archived is null',
115
+ `s.directory = ${sqlString(opts.cwd)}`,
116
+ ];
117
+ const sinceMs = includeAllForShow ? undefined : parseSinceCutoff(opts.since);
118
+ if (sinceMs !== undefined) where.push(`s.time_created >= ${Number(sinceMs)}`);
119
+
120
+ const rows = queryOpenCodeDb(dbPath, [
121
+ 'select',
122
+ ' s.id,',
123
+ ' s.parent_id,',
124
+ ' s.title,',
125
+ ' s.directory,',
126
+ ' s.time_created,',
127
+ ' s.time_updated,',
128
+ ' (select count(*) from message m where m.session_id = s.id) as turn_count,',
129
+ ' (',
130
+ ' (select coalesce(sum(length(data)), 0) from message m where m.session_id = s.id) +',
131
+ ' (select coalesce(sum(length(data)), 0) from part p where p.session_id = s.id)',
132
+ ' ) as size_bytes',
133
+ 'from session s',
134
+ `where ${where.join(' and ')}`,
135
+ 'order by s.time_updated desc',
136
+ ].join(' '));
137
+
138
+ return rows.map((row) => ({
139
+ id: row.id,
140
+ parentId: row.parent_id || null,
141
+ title: row.title || '',
142
+ cwd: row.directory,
143
+ startedAt: msToIso(row.time_created),
144
+ startedAtMs: Number(row.time_created),
145
+ endedAt: msToIso(row.time_updated),
146
+ endedAtMs: Number(row.time_updated),
147
+ turnCount: row.turn_count ?? 0,
148
+ sizeBytes: row.size_bytes ?? 0,
149
+ }));
150
+ }
151
+
152
+ function loadRows(sessionId) {
153
+ const dbPath = defaultOpenCodeDbPath();
154
+ const id = sqlString(sessionId);
155
+ return {
156
+ messages: queryOpenCodeDb(dbPath, `select id, time_created, data from message where session_id = ${id} order by time_created, id`),
157
+ parts: queryOpenCodeDb(dbPath, `select id, message_id, time_created, data from part where session_id = ${id} order by time_created, id`),
158
+ };
159
+ }
160
+
161
+ function parseJson(raw) {
162
+ try {
163
+ return JSON.parse(raw || '{}');
164
+ } catch (error) {
165
+ const message = error instanceof Error ? error.message : String(error);
166
+ return { __parseError: message, __raw: raw || '' };
167
+ }
168
+ }
169
+
170
+ function selectSession(refs, positional) {
171
+ const requested = positional[0];
172
+ if (!requested || requested === 'latest') {
173
+ return refs.find((ref) => !ref.parentId) || refs[0] || null;
174
+ }
175
+ return findSessionById(refs, requested);
176
+ }
177
+
178
+ function sessionsForAudit(selected, allSessions, includeChildren) {
179
+ if (!includeChildren) return [selected];
180
+ const children = allSessions
181
+ .filter((candidate) => candidate.parentId === selected.id)
182
+ .sort((a, b) => a.startedAtMs - b.startedAtMs);
183
+ return [selected, ...children];
184
+ }
185
+
186
+ function buildGuardEvents(sessions) {
187
+ const events = [];
188
+ const rootId = sessions[0]?.id;
189
+
190
+ for (const session of sessions) {
191
+ const rows = loadRows(session.id);
192
+ const messages = rows.messages.map((row) => ({ row, data: parseJson(row.data) }));
193
+ const messageById = new Map(messages.map((message) => [message.row.id, message.data]));
194
+ let requestIndex = 0;
195
+
196
+ for (const row of rows.parts) {
197
+ const data = parseJson(row.data);
198
+ const message = messageById.get(row.message_id) || {};
199
+ const role = message.role || 'unknown';
200
+ const at = msToIso(row.time_created);
201
+ const base = {
202
+ sessionId: session.id,
203
+ sessionTitle: session.title,
204
+ parentId: session.parentId,
205
+ isChildSession: session.id !== rootId,
206
+ role,
207
+ messageId: row.message_id,
208
+ sessionMessageId: `${session.id}:${row.message_id}`,
209
+ partId: row.id,
210
+ requestIndex,
211
+ };
212
+
213
+ if (data.type === 'text') {
214
+ if (role === 'user') requestIndex += 1;
215
+ events.push({
216
+ type: 'message',
217
+ name: role,
218
+ at,
219
+ source: `opencode:${session.id}`,
220
+ text: data.text || '',
221
+ data: { ...base, requestIndex },
222
+ });
223
+ continue;
224
+ }
225
+
226
+ if (data.type === 'tool') {
227
+ const input = objectOrEmpty(data.state?.input);
228
+ const metadata = objectOrEmpty(data.state?.metadata);
229
+ const toolName = data.tool || 'unknown';
230
+ const text = toolText(toolName, input, metadata, data.state);
231
+ const toolEvent = {
232
+ type: 'tool_call',
233
+ name: toolName,
234
+ at,
235
+ source: `opencode:${session.id}`,
236
+ text,
237
+ data: {
238
+ ...base,
239
+ tool: toolName,
240
+ status: stringValue(data.state?.status),
241
+ input,
242
+ metadata,
243
+ },
244
+ };
245
+ events.push(toolEvent);
246
+ events.push(...derivedToolEvents(toolEvent));
247
+ }
248
+ }
249
+ }
250
+
251
+ return events
252
+ .sort((a, b) => String(a.at || '').localeCompare(String(b.at || '')))
253
+ .map((event, index) => ({ ...event, index }));
254
+ }
255
+
256
+ function derivedToolEvents(event) {
257
+ const text = event.text || '';
258
+ const events = [];
259
+ if (runsCommand(text, /\b(npx\s+)?job-forge\s+merge\b|\bnpm\s+run\s+merge\b/)) {
260
+ events.push(derivedToolEvent(event, 'job-forge-merge'));
261
+ }
262
+ if (runsCommand(text, /\b(npx\s+)?job-forge\s+verify\b|\bnpm\s+run\s+verify\b/)) {
263
+ events.push(derivedToolEvent(event, 'job-forge-verify'));
264
+ }
265
+ if (runsCommand(text, /\bgeometra_disconnect\b/)) {
266
+ events.push(derivedToolEvent(event, 'geometra_disconnect'));
267
+ }
268
+ if (runsCommand(text, /\bgeometra_list_sessions\b/)) {
269
+ events.push(derivedToolEvent(event, 'geometra_list_sessions'));
270
+ }
271
+ return events;
272
+ }
273
+
274
+ function derivedToolEvent(event, name) {
275
+ return {
276
+ ...event,
277
+ name,
278
+ data: {
279
+ ...(event.data || {}),
280
+ derivedFrom: event.name,
281
+ },
282
+ };
283
+ }
284
+
285
+ function runsCommand(text, pattern) {
286
+ return /(^|[\s"])(bash|shell|exec|command|terminal|run_command)\b/i.test(text) && pattern.test(text);
287
+ }
288
+
289
+ function toolText(toolName, input, metadata, state) {
290
+ const fragments = [toolName, safeJson(input), safeJson(metadata)];
291
+ if (state?.output && typeof state.output === 'string') fragments.push(state.output);
292
+ return fragments.filter(Boolean).join('\n');
293
+ }
294
+
295
+ function objectOrEmpty(value) {
296
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
297
+ }
298
+
299
+ function stringValue(value) {
300
+ return typeof value === 'string' ? value : value == null ? '' : String(value);
301
+ }
302
+
303
+ function safeJson(value) {
304
+ try {
305
+ return JSON.stringify(value);
306
+ } catch {
307
+ return '';
308
+ }
309
+ }
310
+
311
+ function printablePath(path) {
312
+ const rel = relative(PROJECT_DIR, path);
313
+ return rel && !rel.startsWith('..') ? rel : path;
314
+ }
315
+
316
+ function printAudit({ selected, includedSessions, policy, result }) {
317
+ const children = includedSessions.length - 1;
318
+ console.log(`session: ${selected.id}${selected.title ? ` (${selected.title})` : ''}`);
319
+ if (children > 0) console.log(`children: ${children}`);
320
+ console.log(`policy: ${printablePath(policy.sourcePath || defaultPolicyPath())}`);
321
+ console.log(formatAuditResult(result));
322
+ }
323
+
324
+ async function main() {
325
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
326
+ console.log(USAGE);
327
+ return 0;
328
+ }
329
+
330
+ if (cmd === 'explain') {
331
+ const { opts } = parseArgs(args);
332
+ if (opts.help) {
333
+ console.log(USAGE);
334
+ return 0;
335
+ }
336
+ if (opts.error) {
337
+ console.error(`job-forge guard:explain: ${opts.error}`);
338
+ return 2;
339
+ }
340
+ const policy = loadPolicy(opts.policy);
341
+ if (opts.json) {
342
+ console.log(JSON.stringify(policy, null, 2));
343
+ } else {
344
+ console.log(formatPolicyExplanation(policy));
345
+ }
346
+ return 0;
347
+ }
348
+
349
+ if (cmd === 'audit') {
350
+ const { opts, positional } = parseArgs(args, { allowSession: true });
351
+ if (opts.help) {
352
+ console.log(USAGE);
353
+ return 0;
354
+ }
355
+ if (opts.error) {
356
+ console.error(`job-forge guard:audit: ${opts.error}`);
357
+ return 2;
358
+ }
359
+ const refs = discoverSessions(opts, { includeAllForShow: Boolean(positional[0] && positional[0] !== 'latest') });
360
+ if (refs.length === 0) {
361
+ console.error('job-forge guard:audit: no OpenCode sessions found for this project');
362
+ return 2;
363
+ }
364
+ const selected = selectSession(refs, positional);
365
+ if (!selected) {
366
+ console.error(`job-forge guard:audit: no OpenCode session matches "${positional[0]}"`);
367
+ return 2;
368
+ }
369
+ const includedSessions = sessionsForAudit(selected, refs, opts.includeChildren);
370
+ const policy = loadPolicy(opts.policy);
371
+ const events = buildGuardEvents(includedSessions);
372
+ const result = audit(policy, events);
373
+
374
+ if (opts.json) {
375
+ console.log(JSON.stringify({
376
+ session: selected,
377
+ includedSessions: includedSessions.map((session) => ({
378
+ id: session.id,
379
+ parentId: session.parentId,
380
+ title: session.title,
381
+ startedAt: session.startedAt,
382
+ endedAt: session.endedAt,
383
+ })),
384
+ policy: policy.sourcePath,
385
+ result,
386
+ }, null, 2));
387
+ } else {
388
+ printAudit({ selected, includedSessions, policy, result });
389
+ }
390
+ return resultFails(result, opts.failOn) ? 1 : 0;
391
+ }
392
+
393
+ console.error(`job-forge guard: unknown command "${cmd}"`);
394
+ console.error(USAGE);
395
+ return 2;
396
+ }
397
+
398
+ main()
399
+ .then((code) => process.exit(code))
400
+ .catch((error) => {
401
+ const message = error instanceof Error ? error.message : String(error);
402
+ console.error(message);
403
+ process.exit(1);
404
+ });
@@ -0,0 +1,50 @@
1
+ version: 1
2
+ rules:
3
+ - id: JF-H1
4
+ type: max-per-group
5
+ severity: error
6
+ description: Do not dispatch more than two task subagents from one assistant message.
7
+ match:
8
+ type: tool_call
9
+ name: task
10
+ groupBy: sessionMessageId
11
+ max: 2
12
+
13
+ - id: JF-H5b
14
+ type: forbid-text
15
+ severity: error
16
+ description: Do not use task to poll task/session status.
17
+ match:
18
+ type: tool_call
19
+ name: task
20
+ patterns:
21
+ - source: '"task_id"\s*:'
22
+ - source: '\b(return your final outcome now|report your current status|current status|still working|still running)\b'
23
+ flags: i
24
+ - source: '\b(check|poll|fetch|ask)\b.{0,80}\b(task|session)\b.{0,60}\b(status|state|result|outcome)\b'
25
+ flags: i
26
+
27
+ - id: JF-H5-child-no-task
28
+ type: forbid-text
29
+ severity: error
30
+ description: Child/subagent sessions must not spawn more task subagents.
31
+ match:
32
+ type: tool_call
33
+ name: task
34
+ fields:
35
+ isChildSession: true
36
+ patterns:
37
+ - source: '[\s\S]'
38
+
39
+ - id: JF-H8
40
+ type: forbid-text
41
+ severity: error
42
+ description: Task prompts must not inline proxy configuration or proxy credentials.
43
+ match:
44
+ type: tool_call
45
+ name: task
46
+ patterns:
47
+ - source: '\bproxy\s*:\s*\{'
48
+ flags: i
49
+ - source: '\bproxy[_.-]?(server|username|password|bypass)\b\s*[:=]'
50
+ flags: i