job-forge 2.14.14 → 2.14.16
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 +6 -3
- package/.opencode/skills/job-forge.md +7 -0
- package/AGENTS.md +6 -3
- package/CLAUDE.md +6 -3
- package/README.md +5 -1
- package/bin/create-job-forge.mjs +11 -1
- package/bin/job-forge.mjs +55 -0
- package/docs/ARCHITECTURE.md +7 -1
- package/docs/CUSTOMIZATION.md +25 -0
- package/docs/README.md +1 -1
- package/docs/SETUP.md +5 -0
- package/iso/commands/job-forge.md +7 -0
- package/iso/instructions.md +6 -3
- package/lib/jobforge-ledger.mjs +214 -0
- package/merge-tracker.mjs +23 -0
- package/package.json +10 -1
- package/scripts/guard.mjs +404 -0
- package/scripts/ledger.mjs +359 -0
- package/scripts/telemetry.mjs +14 -0
- package/scripts/tracker-line.mjs +8 -0
- package/templates/guards/jobforge-baseline.yaml +50 -0
- package/verify-pipeline.mjs +21 -0
|
@@ -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
|
+
});
|