job-forge 2.14.32 → 2.14.34
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 +12 -3
- package/.opencode/skills/job-forge.md +4 -0
- package/AGENTS.md +12 -3
- package/CLAUDE.md +12 -3
- package/README.md +11 -5
- package/bin/create-job-forge.mjs +21 -0
- package/bin/job-forge.mjs +103 -0
- package/docs/ARCHITECTURE.md +19 -3
- package/docs/CUSTOMIZATION.md +12 -0
- package/docs/README.md +1 -1
- package/docs/SETUP.md +7 -0
- package/iso/commands/job-forge.md +4 -0
- package/iso/instructions.md +12 -3
- package/lib/jobforge-lineage.mjs +122 -0
- package/lib/jobforge-prioritize.mjs +294 -0
- package/lib/jobforge-timeline.mjs +294 -0
- package/modes/followup.md +6 -6
- package/package.json +25 -1
- package/scripts/check-iso-smoke.mjs +3 -0
- package/scripts/lineage.mjs +247 -0
- package/scripts/prioritize.mjs +323 -0
- package/scripts/timeline.mjs +237 -0
- package/templates/migrations.json +27 -0
- package/templates/prioritize.json +125 -0
- package/templates/timeline.json +86 -0
- package/verify-pipeline.mjs +68 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, isAbsolute, join, relative } from 'path';
|
|
3
|
+
import {
|
|
4
|
+
checkTimeline,
|
|
5
|
+
filterTimelineResult,
|
|
6
|
+
loadTimelineConfig,
|
|
7
|
+
parseJson,
|
|
8
|
+
parseJsonLines,
|
|
9
|
+
planTimeline,
|
|
10
|
+
verifyTimelineResult,
|
|
11
|
+
} from '@razroo/iso-timeline';
|
|
12
|
+
import { DATA_APPS_DIR, PROJECT_DIR, readAllEntries } from '../tracker-lib.mjs';
|
|
13
|
+
import { jobForgeCompanyRoleKey, jobForgeUrlKey, legacyCompanyRoleKey, legacyUrlKey } from './jobforge-canon.mjs';
|
|
14
|
+
|
|
15
|
+
export const TIMELINE_CONFIG_FILE = 'templates/timeline.json';
|
|
16
|
+
export const TIMELINE_FILE = '.jobforge-timeline.json';
|
|
17
|
+
export const TIMELINE_EVENTS_FILE = '.jobforge-timeline-events.jsonl';
|
|
18
|
+
export const USER_TIMELINE_EVENTS_FILE = 'data/timeline-events.jsonl';
|
|
19
|
+
|
|
20
|
+
export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
|
|
21
|
+
return projectDir;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function jobForgeTimelineConfigPath(projectDir = resolveProjectDir()) {
|
|
25
|
+
return process.env.JOB_FORGE_TIMELINE_CONFIG || join(projectDir, TIMELINE_CONFIG_FILE);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function jobForgeTimelinePath(projectDir = resolveProjectDir()) {
|
|
29
|
+
return process.env.JOB_FORGE_TIMELINE || join(projectDir, TIMELINE_FILE);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function jobForgeTimelineEventsPath(projectDir = resolveProjectDir()) {
|
|
33
|
+
return process.env.JOB_FORGE_TIMELINE_EVENTS || join(projectDir, TIMELINE_EVENTS_FILE);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readJobForgeTimelineConfig(projectDir = resolveProjectDir()) {
|
|
37
|
+
const path = jobForgeTimelineConfigPath(projectDir);
|
|
38
|
+
return loadTimelineConfig(parseJson(readFileSync(path, 'utf8'), path));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function timelineExists(projectDir = resolveProjectDir()) {
|
|
42
|
+
return existsSync(jobForgeTimelinePath(projectDir));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function timelineEventsExist(projectDir = resolveProjectDir()) {
|
|
46
|
+
return existsSync(jobForgeTimelineEventsPath(projectDir));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readJobForgeTimeline(projectDir = resolveProjectDir()) {
|
|
50
|
+
const path = jobForgeTimelinePath(projectDir);
|
|
51
|
+
return parseJson(readFileSync(path, 'utf8'), path);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildJobForgeTimelineEvents(projectDir = resolveProjectDir()) {
|
|
55
|
+
const events = [
|
|
56
|
+
...applicationEvents(projectDir),
|
|
57
|
+
...pipelineEvents(projectDir),
|
|
58
|
+
...userEvents(projectDir),
|
|
59
|
+
];
|
|
60
|
+
events.sort(compareEvents);
|
|
61
|
+
return events;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function writeJobForgeTimelineEvents(events, options = {}, projectDir = resolveProjectDir()) {
|
|
65
|
+
const out = options.out || jobForgeTimelineEventsPath(projectDir);
|
|
66
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
67
|
+
const content = events.map((event) => JSON.stringify(event)).join('\n');
|
|
68
|
+
writeFileSync(out, `${content}${content ? '\n' : ''}`, 'utf8');
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function planJobForgeTimeline(options = {}, projectDir = resolveProjectDir()) {
|
|
73
|
+
const config = readJobForgeTimelineConfig(projectDir);
|
|
74
|
+
const events = options.events || buildJobForgeTimelineEvents(projectDir);
|
|
75
|
+
return planTimeline(config, events, { now: options.now });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function dueJobForgeTimeline(options = {}, projectDir = resolveProjectDir()) {
|
|
79
|
+
return filterTimelineResult(planJobForgeTimeline(options, projectDir), ['overdue', 'due']);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function checkJobForgeTimeline(options = {}, projectDir = resolveProjectDir()) {
|
|
83
|
+
const config = readJobForgeTimelineConfig(projectDir);
|
|
84
|
+
const events = options.events || buildJobForgeTimelineEvents(projectDir);
|
|
85
|
+
return checkTimeline(config, events, { now: options.now, failOn: options.failOn });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildJobForgeTimeline(options = {}, projectDir = resolveProjectDir()) {
|
|
89
|
+
const events = buildJobForgeTimelineEvents(projectDir);
|
|
90
|
+
const result = planJobForgeTimeline({ now: options.now, events }, projectDir);
|
|
91
|
+
const eventsOut = writeJobForgeTimelineEvents(events, { out: options.eventsOut }, projectDir);
|
|
92
|
+
const out = options.out || jobForgeTimelinePath(projectDir);
|
|
93
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
94
|
+
writeFileSync(out, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
|
95
|
+
return { result, events, out, eventsOut };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function verifyJobForgeTimeline(options = {}, projectDir = resolveProjectDir()) {
|
|
99
|
+
const result = options.result || readJobForgeTimeline(projectDir);
|
|
100
|
+
return verifyTimelineResult(result);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function jobForgeTimelineSummary(projectDir = resolveProjectDir()) {
|
|
104
|
+
if (!timelineExists(projectDir)) {
|
|
105
|
+
return {
|
|
106
|
+
path: jobForgeTimelinePath(projectDir),
|
|
107
|
+
eventsPath: jobForgeTimelineEventsPath(projectDir),
|
|
108
|
+
config: jobForgeTimelineConfigPath(projectDir),
|
|
109
|
+
exists: false,
|
|
110
|
+
eventsExists: timelineEventsExist(projectDir),
|
|
111
|
+
items: 0,
|
|
112
|
+
due: 0,
|
|
113
|
+
overdue: 0,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const result = readJobForgeTimeline(projectDir);
|
|
117
|
+
return {
|
|
118
|
+
path: jobForgeTimelinePath(projectDir),
|
|
119
|
+
eventsPath: jobForgeTimelineEventsPath(projectDir),
|
|
120
|
+
config: jobForgeTimelineConfigPath(projectDir),
|
|
121
|
+
exists: true,
|
|
122
|
+
eventsExists: timelineEventsExist(projectDir),
|
|
123
|
+
items: result.stats?.total || 0,
|
|
124
|
+
due: result.stats?.due || 0,
|
|
125
|
+
overdue: result.stats?.overdue || 0,
|
|
126
|
+
generatedAt: result.generatedAt,
|
|
127
|
+
id: result.id,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function applicationEvents(projectDir) {
|
|
132
|
+
const { entries } = readAllEntries();
|
|
133
|
+
return entries
|
|
134
|
+
.map((entry) => applicationEvent(entry, projectDir))
|
|
135
|
+
.filter(Boolean);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function applicationEvent(entry, projectDir) {
|
|
139
|
+
const at = dateToIso(entry.date);
|
|
140
|
+
if (!at || !entry.company || !entry.role || !entry.status) return null;
|
|
141
|
+
const status = canonicalStatus(entry.status);
|
|
142
|
+
const key = safeCompanyRoleKey(entry.company, entry.role, projectDir);
|
|
143
|
+
return {
|
|
144
|
+
id: `jobforge:application-status:${entry.num}:${key}:${at}`,
|
|
145
|
+
key,
|
|
146
|
+
type: 'application.status',
|
|
147
|
+
at,
|
|
148
|
+
data: compactObject({
|
|
149
|
+
num: entry.num,
|
|
150
|
+
date: entry.date,
|
|
151
|
+
company: entry.company,
|
|
152
|
+
role: entry.role,
|
|
153
|
+
score: entry.score,
|
|
154
|
+
status,
|
|
155
|
+
pdf: entry.pdf,
|
|
156
|
+
report: entry.report,
|
|
157
|
+
notes: entry.notes,
|
|
158
|
+
}),
|
|
159
|
+
source: sourceForApplication(entry, projectDir),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function pipelineEvents(projectDir) {
|
|
164
|
+
const pipelinePath = join(projectDir, 'data', 'pipeline.md');
|
|
165
|
+
if (!existsSync(pipelinePath)) return [];
|
|
166
|
+
const scanDates = scanHistoryDates(projectDir);
|
|
167
|
+
const lines = readFileSync(pipelinePath, 'utf8').split('\n');
|
|
168
|
+
const events = [];
|
|
169
|
+
lines.forEach((line, index) => {
|
|
170
|
+
const match = line.match(/^\s*-\s*\[([ xX])\]\s+(https?:\/\/[^|\s#]+)(.*)$/);
|
|
171
|
+
if (!match) return;
|
|
172
|
+
const url = match[2].trim();
|
|
173
|
+
const at = dateToIso(scanDates.get(url) || firstDateInText(line));
|
|
174
|
+
if (!at) return;
|
|
175
|
+
const fields = (match[3] || '').split('|').map((field) => field.trim()).filter(Boolean);
|
|
176
|
+
const status = match[1].toLowerCase() === 'x' ? 'processed' : 'pending';
|
|
177
|
+
const key = safeUrlKey(url, projectDir);
|
|
178
|
+
events.push({
|
|
179
|
+
id: `jobforge:pipeline:${status}:${key}:${at}`,
|
|
180
|
+
key,
|
|
181
|
+
type: status === 'processed' ? 'pipeline.processed' : 'pipeline.item',
|
|
182
|
+
at,
|
|
183
|
+
data: compactObject({
|
|
184
|
+
status,
|
|
185
|
+
url,
|
|
186
|
+
company: fields[0],
|
|
187
|
+
role: fields[1],
|
|
188
|
+
}),
|
|
189
|
+
source: {
|
|
190
|
+
path: 'data/pipeline.md',
|
|
191
|
+
line: index + 1,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
return events;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function userEvents(projectDir) {
|
|
199
|
+
const path = join(projectDir, USER_TIMELINE_EVENTS_FILE);
|
|
200
|
+
if (!existsSync(path)) return [];
|
|
201
|
+
return parseJsonLines(readFileSync(path, 'utf8'), path);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function scanHistoryDates(projectDir) {
|
|
205
|
+
const path = join(projectDir, 'data', 'scan-history.tsv');
|
|
206
|
+
const dates = new Map();
|
|
207
|
+
if (!existsSync(path)) return dates;
|
|
208
|
+
const lines = readFileSync(path, 'utf8').split('\n');
|
|
209
|
+
for (const line of lines) {
|
|
210
|
+
if (!line.trim()) continue;
|
|
211
|
+
const parts = line.split('\t');
|
|
212
|
+
if (parts.length < 4) continue;
|
|
213
|
+
const date = parts[0]?.trim();
|
|
214
|
+
const url = parts[3]?.trim();
|
|
215
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(date) && /^https?:\/\//.test(url)) dates.set(url, date);
|
|
216
|
+
}
|
|
217
|
+
return dates;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function sourceForApplication(entry, projectDir) {
|
|
221
|
+
const raw = String(entry._sourceFile || '');
|
|
222
|
+
if (/^\d{4}-\d{2}-\d{2}\.md$/.test(raw)) {
|
|
223
|
+
return { path: relativePath(projectDir, join(DATA_APPS_DIR, raw)) };
|
|
224
|
+
}
|
|
225
|
+
if (raw) return { path: relativePath(projectDir, raw) };
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function dateToIso(value) {
|
|
230
|
+
const text = String(value || '').trim();
|
|
231
|
+
if (!text) return null;
|
|
232
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return `${text}T12:00:00.000Z`;
|
|
233
|
+
const parsed = new Date(text);
|
|
234
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function firstDateInText(value) {
|
|
238
|
+
return String(value || '').match(/\b\d{4}-\d{2}-\d{2}\b/)?.[0] || '';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function canonicalStatus(value) {
|
|
242
|
+
const text = String(value || '').trim();
|
|
243
|
+
const lower = text.toLowerCase();
|
|
244
|
+
const map = new Map([
|
|
245
|
+
['evaluated', 'Evaluated'],
|
|
246
|
+
['applied', 'Applied'],
|
|
247
|
+
['responded', 'Responded'],
|
|
248
|
+
['contacted', 'Contacted'],
|
|
249
|
+
['interview', 'Interview'],
|
|
250
|
+
['offer', 'Offer'],
|
|
251
|
+
['rejected', 'Rejected'],
|
|
252
|
+
['discarded', 'Discarded'],
|
|
253
|
+
['failed', 'Failed'],
|
|
254
|
+
['skip', 'SKIP'],
|
|
255
|
+
]);
|
|
256
|
+
return map.get(lower) || text;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function safeCompanyRoleKey(company, role, projectDir) {
|
|
260
|
+
try {
|
|
261
|
+
return jobForgeCompanyRoleKey(company, role, projectDir);
|
|
262
|
+
} catch {
|
|
263
|
+
return legacyCompanyRoleKey(company, role);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function safeUrlKey(url, projectDir) {
|
|
268
|
+
try {
|
|
269
|
+
return jobForgeUrlKey(url, projectDir);
|
|
270
|
+
} catch {
|
|
271
|
+
return legacyUrlKey(url);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function compactObject(obj) {
|
|
276
|
+
const out = {};
|
|
277
|
+
for (const [key, value] of Object.entries(obj || {})) {
|
|
278
|
+
if (value === undefined || value === null || value === '') continue;
|
|
279
|
+
out[key] = value;
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function compareEvents(a, b) {
|
|
285
|
+
return `${a.at}\0${a.key}\0${a.type}\0${a.id || ''}`.localeCompare(`${b.at}\0${b.key}\0${b.type}\0${b.id || ''}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function relativePath(projectDir, value) {
|
|
289
|
+
const text = String(value || '');
|
|
290
|
+
if (!text) return '';
|
|
291
|
+
const rel = relative(projectDir, text);
|
|
292
|
+
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return rel.replace(/\\/g, '/');
|
|
293
|
+
return text.replace(/\\/g, '/');
|
|
294
|
+
}
|
package/modes/followup.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Mode: followup — Follow-Up Timing & Nudge System
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Uses `job-forge timeline:*` to scan local tracker and dated pipeline sources for entries that need follow-up action based on their current state and how long they've been in that state.
|
|
4
4
|
|
|
5
5
|
**This mode is read-only on existing pipeline logic.** It reads the tracker and suggests actions — it never changes scores, reports, or pipeline behavior.
|
|
6
6
|
|
|
@@ -19,11 +19,11 @@ Scans all day files in `data/applications/` for entries that need follow-up acti
|
|
|
19
19
|
|
|
20
20
|
## Run This Workflow
|
|
21
21
|
|
|
22
|
-
1.
|
|
23
|
-
2.
|
|
24
|
-
3.
|
|
25
|
-
4.
|
|
26
|
-
5.
|
|
22
|
+
1. Run `npx job-forge timeline:due` first. It rebuilds the due queue from local tracker/pipeline sources without loading growing files into prompt context.
|
|
23
|
+
2. If the user wants a persistent artifact, run `npx job-forge timeline:build`.
|
|
24
|
+
3. Use `npx job-forge timeline:check --fail-on overdue` when the workflow should fail only on stale actions.
|
|
25
|
+
4. Present the action list grouped by `OVERDUE`, `DUE`, and upcoming manual context if needed.
|
|
26
|
+
5. Only read individual tracker/report files after the user selects an action that needs message drafting.
|
|
27
27
|
|
|
28
28
|
```
|
|
29
29
|
## Follow-Up Actions — {today's date}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-forge",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.34",
|
|
4
4
|
"description": "AI-powered job search pipeline built on opencode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -78,6 +78,27 @@
|
|
|
78
78
|
"postflight:status": "node bin/job-forge.mjs postflight:status",
|
|
79
79
|
"postflight:check": "node bin/job-forge.mjs postflight:check",
|
|
80
80
|
"postflight:explain": "node bin/job-forge.mjs postflight:explain",
|
|
81
|
+
"timeline:status": "node bin/job-forge.mjs timeline:status",
|
|
82
|
+
"timeline:build": "node bin/job-forge.mjs timeline:build",
|
|
83
|
+
"timeline:plan": "node bin/job-forge.mjs timeline:plan",
|
|
84
|
+
"timeline:due": "node bin/job-forge.mjs timeline:due",
|
|
85
|
+
"timeline:check": "node bin/job-forge.mjs timeline:check",
|
|
86
|
+
"timeline:verify": "node bin/job-forge.mjs timeline:verify",
|
|
87
|
+
"timeline:explain": "node bin/job-forge.mjs timeline:explain",
|
|
88
|
+
"prioritize:status": "node bin/job-forge.mjs prioritize:status",
|
|
89
|
+
"prioritize:items": "node bin/job-forge.mjs prioritize:items",
|
|
90
|
+
"prioritize:build": "node bin/job-forge.mjs prioritize:build",
|
|
91
|
+
"prioritize:rank": "node bin/job-forge.mjs prioritize:rank",
|
|
92
|
+
"prioritize:select": "node bin/job-forge.mjs prioritize:select",
|
|
93
|
+
"prioritize:check": "node bin/job-forge.mjs prioritize:check",
|
|
94
|
+
"prioritize:verify": "node bin/job-forge.mjs prioritize:verify",
|
|
95
|
+
"prioritize:explain": "node bin/job-forge.mjs prioritize:explain",
|
|
96
|
+
"lineage:status": "node bin/job-forge.mjs lineage:status",
|
|
97
|
+
"lineage:record": "node bin/job-forge.mjs lineage:record",
|
|
98
|
+
"lineage:check": "node bin/job-forge.mjs lineage:check",
|
|
99
|
+
"lineage:stale": "node bin/job-forge.mjs lineage:stale",
|
|
100
|
+
"lineage:verify": "node bin/job-forge.mjs lineage:verify",
|
|
101
|
+
"lineage:explain": "node bin/job-forge.mjs lineage:explain",
|
|
81
102
|
"redact:scan": "node bin/job-forge.mjs redact:scan",
|
|
82
103
|
"redact:verify": "node bin/job-forge.mjs redact:verify",
|
|
83
104
|
"redact:apply": "node bin/job-forge.mjs redact:apply",
|
|
@@ -161,12 +182,15 @@
|
|
|
161
182
|
"@razroo/iso-guard": "^0.1.0",
|
|
162
183
|
"@razroo/iso-index": "^0.1.0",
|
|
163
184
|
"@razroo/iso-ledger": "^0.1.0",
|
|
185
|
+
"@razroo/iso-lineage": "^0.1.0",
|
|
164
186
|
"@razroo/iso-migrate": "^0.1.0",
|
|
165
187
|
"@razroo/iso-orchestrator": "^0.1.0",
|
|
166
188
|
"@razroo/iso-postflight": "^0.1.0",
|
|
167
189
|
"@razroo/iso-preflight": "^0.1.0",
|
|
190
|
+
"@razroo/iso-prioritize": "^0.1.0",
|
|
168
191
|
"@razroo/iso-redact": "^0.1.0",
|
|
169
192
|
"@razroo/iso-score": "^0.1.0",
|
|
193
|
+
"@razroo/iso-timeline": "^0.1.0",
|
|
170
194
|
"@razroo/iso-trace": "^0.4.0",
|
|
171
195
|
"playwright": "^1.58.1"
|
|
172
196
|
},
|
|
@@ -20,6 +20,9 @@ const checks = [
|
|
|
20
20
|
["H6 requires merge and verify", () => every(files.instructions, ["batch/tracker-additions/*.tsv", "npx job-forge merge", "npx job-forge verify"])],
|
|
21
21
|
["H7 distrusts subagent prose", () => every(files.instructions, ["must originate from a file", "not from prior subagent prose"])],
|
|
22
22
|
["score policy points to local helper", () => every(files.instructions, ["[D19]", "templates/score.json", "npx job-forge score:check", "npx job-forge score:gate"])],
|
|
23
|
+
["timeline policy points to local helper", () => every(files.instructions, ["[D20]", "templates/timeline.json", "npx job-forge timeline:due", "npx job-forge timeline:check --fail-on overdue"])],
|
|
24
|
+
["prioritize policy points to local helper", () => every(files.instructions, ["[D21]", "templates/prioritize.json", "npx job-forge prioritize:build", "npx job-forge prioritize:select --limit N"])],
|
|
25
|
+
["lineage policy points to local helper", () => every(files.instructions, ["[D22]", ".jobforge-lineage.json", "npx job-forge lineage:record", "npx job-forge lineage:check"])],
|
|
23
26
|
["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"])],
|
|
24
27
|
["apply mode owns high-stakes upgrade", () => every(files.apply, ["[D8]", "@general-paid", "4.0/5", "high-stakes"])],
|
|
25
28
|
["apply mode blocks provider auto-downgrade", () => every(files.apply, ["[D9]", "do not auto-downgrade", "inspect telemetry before retrying"])],
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { relative, resolve } from 'path';
|
|
4
|
+
import {
|
|
5
|
+
formatCheckResult,
|
|
6
|
+
formatExplainGraph,
|
|
7
|
+
formatRecordResult,
|
|
8
|
+
formatStaleResult,
|
|
9
|
+
formatVerifyResult,
|
|
10
|
+
parseJson,
|
|
11
|
+
} from '@razroo/iso-lineage';
|
|
12
|
+
import { PROJECT_DIR } from '../tracker-lib.mjs';
|
|
13
|
+
import {
|
|
14
|
+
checkJobForgeLineage,
|
|
15
|
+
jobForgeLineagePath,
|
|
16
|
+
jobForgeLineageSummary,
|
|
17
|
+
lineageExists,
|
|
18
|
+
normalizeJobForgeLineageArtifact,
|
|
19
|
+
readJobForgeLineage,
|
|
20
|
+
readJobForgeLineageOrEmpty,
|
|
21
|
+
recordJobForgeLineage,
|
|
22
|
+
staleJobForgeLineage,
|
|
23
|
+
verifyJobForgeLineage,
|
|
24
|
+
} from '../lib/jobforge-lineage.mjs';
|
|
25
|
+
|
|
26
|
+
const USAGE = `job-forge lineage - deterministic artifact lineage and stale-output checks
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
job-forge lineage:status [--json]
|
|
30
|
+
job-forge lineage:record --artifact <file> [--input <file>...] [--optional-input <file>...] [--kind <kind>] [--command <cmd>] [--metadata <json>] [--now <iso>] [--json]
|
|
31
|
+
job-forge lineage:check [--artifact <file>] [--json]
|
|
32
|
+
job-forge lineage:stale [--json]
|
|
33
|
+
job-forge lineage:verify [--json]
|
|
34
|
+
job-forge lineage:explain [--artifact <file>] [--json]
|
|
35
|
+
job-forge lineage:path
|
|
36
|
+
|
|
37
|
+
Default graph is .jobforge-lineage.json. Record generated reports, PDFs, and
|
|
38
|
+
other derived artifacts against their source CV/profile/report inputs, then
|
|
39
|
+
check whether outputs are stale after inputs change.`;
|
|
40
|
+
|
|
41
|
+
const [cmd = 'help', ...rawArgs] = process.argv.slice(2);
|
|
42
|
+
const opts = parseArgs(rawArgs);
|
|
43
|
+
|
|
44
|
+
if (opts.help || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
45
|
+
console.log(USAGE);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
if (cmd === 'path') {
|
|
51
|
+
console.log(jobForgeLineagePath(PROJECT_DIR));
|
|
52
|
+
} else if (cmd === 'status') {
|
|
53
|
+
status(opts);
|
|
54
|
+
} else if (cmd === 'record') {
|
|
55
|
+
record(opts);
|
|
56
|
+
} else if (cmd === 'check') {
|
|
57
|
+
check(opts);
|
|
58
|
+
} else if (cmd === 'stale') {
|
|
59
|
+
stale(opts);
|
|
60
|
+
} else if (cmd === 'verify') {
|
|
61
|
+
verify(opts);
|
|
62
|
+
} else if (cmd === 'explain') {
|
|
63
|
+
explain(opts);
|
|
64
|
+
} else {
|
|
65
|
+
console.error(`unknown lineage command "${cmd}"\n`);
|
|
66
|
+
console.error(USAGE);
|
|
67
|
+
process.exit(2);
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseArgs(args) {
|
|
75
|
+
const opts = {
|
|
76
|
+
json: false,
|
|
77
|
+
help: false,
|
|
78
|
+
inputs: [],
|
|
79
|
+
optionalInputs: [],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
for (let i = 0; i < args.length; i++) {
|
|
83
|
+
const arg = args[i];
|
|
84
|
+
if (arg === '--json') {
|
|
85
|
+
opts.json = true;
|
|
86
|
+
} else if (arg === '--artifact') {
|
|
87
|
+
opts.artifact = valueAfter(args, ++i, '--artifact');
|
|
88
|
+
} else if (arg.startsWith('--artifact=')) {
|
|
89
|
+
opts.artifact = arg.slice('--artifact='.length);
|
|
90
|
+
} else if (arg === '--input') {
|
|
91
|
+
opts.inputs.push(valueAfter(args, ++i, '--input'));
|
|
92
|
+
} else if (arg.startsWith('--input=')) {
|
|
93
|
+
opts.inputs.push(arg.slice('--input='.length));
|
|
94
|
+
} else if (arg === '--optional-input') {
|
|
95
|
+
opts.optionalInputs.push(valueAfter(args, ++i, '--optional-input'));
|
|
96
|
+
} else if (arg.startsWith('--optional-input=')) {
|
|
97
|
+
opts.optionalInputs.push(arg.slice('--optional-input='.length));
|
|
98
|
+
} else if (arg === '--kind') {
|
|
99
|
+
opts.kind = valueAfter(args, ++i, '--kind');
|
|
100
|
+
} else if (arg.startsWith('--kind=')) {
|
|
101
|
+
opts.kind = arg.slice('--kind='.length);
|
|
102
|
+
} else if (arg === '--command') {
|
|
103
|
+
opts.command = valueAfter(args, ++i, '--command');
|
|
104
|
+
} else if (arg.startsWith('--command=')) {
|
|
105
|
+
opts.command = arg.slice('--command='.length);
|
|
106
|
+
} else if (arg === '--metadata') {
|
|
107
|
+
opts.metadata = parseMetadata(valueAfter(args, ++i, '--metadata'));
|
|
108
|
+
} else if (arg.startsWith('--metadata=')) {
|
|
109
|
+
opts.metadata = parseMetadata(arg.slice('--metadata='.length));
|
|
110
|
+
} else if (arg === '--now') {
|
|
111
|
+
opts.now = valueAfter(args, ++i, '--now');
|
|
112
|
+
} else if (arg.startsWith('--now=')) {
|
|
113
|
+
opts.now = arg.slice('--now='.length);
|
|
114
|
+
} else if (arg === '--out') {
|
|
115
|
+
opts.out = resolve(PROJECT_DIR, valueAfter(args, ++i, '--out'));
|
|
116
|
+
} else if (arg.startsWith('--out=')) {
|
|
117
|
+
opts.out = resolve(PROJECT_DIR, arg.slice('--out='.length));
|
|
118
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
119
|
+
opts.help = true;
|
|
120
|
+
} else {
|
|
121
|
+
throw new Error(`unknown flag "${arg}"`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return opts;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function valueAfter(values, index, flag) {
|
|
129
|
+
const value = values[index];
|
|
130
|
+
if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseMetadata(value) {
|
|
135
|
+
const parsed = parseJson(value, '--metadata');
|
|
136
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
137
|
+
throw new Error('--metadata must be a JSON object');
|
|
138
|
+
}
|
|
139
|
+
return parsed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function status(opts) {
|
|
143
|
+
const summary = jobForgeLineageSummary(PROJECT_DIR);
|
|
144
|
+
if (opts.json) {
|
|
145
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!summary.exists) {
|
|
149
|
+
console.log(`lineage: missing (${relativePath(summary.path)})`);
|
|
150
|
+
console.log('run: job-forge lineage:record --artifact <file> --input <file>');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
console.log(`lineage: ${relativePath(summary.path)}`);
|
|
154
|
+
console.log(`records: ${summary.records}`);
|
|
155
|
+
console.log(`current: ${summary.current}`);
|
|
156
|
+
console.log(`stale: ${summary.stale}`);
|
|
157
|
+
console.log(`missing: ${summary.missing}`);
|
|
158
|
+
console.log(`check: ${summary.ok ? 'PASS' : 'STALE'}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function record(opts) {
|
|
162
|
+
const result = recordJobForgeLineage({
|
|
163
|
+
artifact: opts.artifact,
|
|
164
|
+
inputs: opts.inputs,
|
|
165
|
+
optionalInputs: opts.optionalInputs,
|
|
166
|
+
kind: opts.kind,
|
|
167
|
+
command: opts.command,
|
|
168
|
+
metadata: opts.metadata,
|
|
169
|
+
now: opts.now,
|
|
170
|
+
out: opts.out,
|
|
171
|
+
}, PROJECT_DIR);
|
|
172
|
+
if (opts.json) {
|
|
173
|
+
console.log(JSON.stringify(result, null, 2));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
console.log(formatRecordResult(result.graph, result.record, relativePath(result.out)));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function check(opts) {
|
|
180
|
+
if (!lineageExists(PROJECT_DIR)) {
|
|
181
|
+
if (opts.json) {
|
|
182
|
+
console.log(JSON.stringify({ ok: true, missing: true, path: jobForgeLineagePath(PROJECT_DIR) }, null, 2));
|
|
183
|
+
} else {
|
|
184
|
+
console.log(`lineage: missing (${relativePath(jobForgeLineagePath(PROJECT_DIR))})`);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const result = checkJobForgeLineage({ artifact: opts.artifact }, PROJECT_DIR);
|
|
189
|
+
if (opts.json) {
|
|
190
|
+
console.log(JSON.stringify(result, null, 2));
|
|
191
|
+
} else {
|
|
192
|
+
console.log(formatCheckResult(result));
|
|
193
|
+
}
|
|
194
|
+
process.exit(result.ok ? 0 : 1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function stale(opts) {
|
|
198
|
+
if (!lineageExists(PROJECT_DIR)) {
|
|
199
|
+
if (opts.json) {
|
|
200
|
+
console.log(JSON.stringify({ ok: true, missing: true, path: jobForgeLineagePath(PROJECT_DIR) }, null, 2));
|
|
201
|
+
} else {
|
|
202
|
+
console.log(`lineage: missing (${relativePath(jobForgeLineagePath(PROJECT_DIR))})`);
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const result = staleJobForgeLineage({}, PROJECT_DIR);
|
|
207
|
+
if (opts.json) {
|
|
208
|
+
console.log(JSON.stringify(result, null, 2));
|
|
209
|
+
} else {
|
|
210
|
+
console.log(formatStaleResult(result));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function verify(opts) {
|
|
215
|
+
if (!lineageExists(PROJECT_DIR)) {
|
|
216
|
+
if (opts.json) {
|
|
217
|
+
console.log(JSON.stringify({ ok: true, missing: true, path: jobForgeLineagePath(PROJECT_DIR) }, null, 2));
|
|
218
|
+
} else {
|
|
219
|
+
console.log(`lineage: missing (${relativePath(jobForgeLineagePath(PROJECT_DIR))})`);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const result = verifyJobForgeLineage({}, PROJECT_DIR);
|
|
224
|
+
if (opts.json) {
|
|
225
|
+
console.log(JSON.stringify(result, null, 2));
|
|
226
|
+
} else {
|
|
227
|
+
console.log(formatVerifyResult(result));
|
|
228
|
+
}
|
|
229
|
+
process.exit(result.ok ? 0 : 1);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function explain(opts) {
|
|
233
|
+
const graph = lineageExists(PROJECT_DIR) ? readJobForgeLineage(PROJECT_DIR) : readJobForgeLineageOrEmpty(PROJECT_DIR);
|
|
234
|
+
const artifact = opts.artifact
|
|
235
|
+
? normalizeJobForgeLineageArtifact(PROJECT_DIR, opts.artifact)
|
|
236
|
+
: undefined;
|
|
237
|
+
if (opts.json) {
|
|
238
|
+
const records = artifact ? graph.records.filter((record) => record.artifact.path === artifact) : graph.records;
|
|
239
|
+
console.log(JSON.stringify({ ...graph, records }, null, 2));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
console.log(formatExplainGraph(graph, artifact));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function relativePath(path) {
|
|
246
|
+
return relative(PROJECT_DIR, path) || '.';
|
|
247
|
+
}
|