job-forge 2.14.25 → 2.14.27

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.
@@ -0,0 +1,88 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import {
4
+ canonicalizeCompany,
5
+ canonicalizeCompanyRole,
6
+ canonicalizeEntity,
7
+ canonicalizeRole,
8
+ canonicalizeUrl,
9
+ compareCanon,
10
+ loadCanonConfig,
11
+ parseJson,
12
+ resolveProfile,
13
+ } from '@razroo/iso-canon';
14
+
15
+ export const CANON_CONFIG_FILE = 'templates/canon.json';
16
+ export const CANON_PROFILE = 'jobforge';
17
+
18
+ export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
19
+ return projectDir;
20
+ }
21
+
22
+ export function jobForgeCanonConfigPath(projectDir = resolveProjectDir()) {
23
+ return process.env.JOB_FORGE_CANON_CONFIG || join(projectDir, CANON_CONFIG_FILE);
24
+ }
25
+
26
+ export function readJobForgeCanonConfig(projectDir = resolveProjectDir()) {
27
+ const path = jobForgeCanonConfigPath(projectDir);
28
+ return loadCanonConfig(parseJson(readFileSync(path, 'utf8'), path), path);
29
+ }
30
+
31
+ export function jobForgeCanonProfile(projectDir = resolveProjectDir()) {
32
+ return resolveProfile(readJobForgeCanonConfig(projectDir), process.env.JOB_FORGE_CANON_PROFILE || CANON_PROFILE);
33
+ }
34
+
35
+ export function canonicalizeJobForgeUrl(url, projectDir = resolveProjectDir()) {
36
+ return canonicalizeUrl(url, jobForgeCanonProfile(projectDir));
37
+ }
38
+
39
+ export function canonicalizeJobForgeCompany(company, projectDir = resolveProjectDir()) {
40
+ return canonicalizeCompany(company, jobForgeCanonProfile(projectDir));
41
+ }
42
+
43
+ export function canonicalizeJobForgeRole(role, projectDir = resolveProjectDir()) {
44
+ return canonicalizeRole(role, jobForgeCanonProfile(projectDir));
45
+ }
46
+
47
+ export function canonicalizeJobForgeCompanyRole(company, role, projectDir = resolveProjectDir()) {
48
+ return canonicalizeCompanyRole(company, role, jobForgeCanonProfile(projectDir));
49
+ }
50
+
51
+ export function canonicalizeJobForgeEntity(type, input, projectDir = resolveProjectDir()) {
52
+ return canonicalizeEntity(type, input, jobForgeCanonProfile(projectDir));
53
+ }
54
+
55
+ export function compareJobForgeCanon(type, left, right, projectDir = resolveProjectDir()) {
56
+ return compareCanon(type, left, right, jobForgeCanonProfile(projectDir));
57
+ }
58
+
59
+ export function jobForgeUrlKey(url, projectDir = resolveProjectDir()) {
60
+ return canonicalizeJobForgeUrl(url, projectDir).key;
61
+ }
62
+
63
+ export function jobForgeCompanyRoleKey(company, role, projectDir = resolveProjectDir()) {
64
+ return canonicalizeJobForgeCompanyRole(company, role, projectDir).key;
65
+ }
66
+
67
+ export function jobForgeApplicationSubject(company, role, projectDir = resolveProjectDir()) {
68
+ const companyKey = canonicalizeJobForgeCompany(company, projectDir).key.slice('company:'.length);
69
+ const roleKey = canonicalizeJobForgeRole(role, projectDir).key.slice('role:'.length);
70
+ return `application:${companyKey}:${roleKey}`;
71
+ }
72
+
73
+ export function legacyCompanyRoleKey(company, role) {
74
+ return `company-role:${legacySlugPart(company)}:${legacySlugPart(role)}`;
75
+ }
76
+
77
+ export function legacyUrlKey(url) {
78
+ return `url:${String(url || '').trim()}`;
79
+ }
80
+
81
+ export function legacySlugPart(value) {
82
+ const slug = String(value || 'unknown')
83
+ .toLowerCase()
84
+ .replace(/&/g, ' and ')
85
+ .replace(/[^a-z0-9]+/g, '-')
86
+ .replace(/^-+|-+$/g, '');
87
+ return slug || 'unknown';
88
+ }
@@ -6,8 +6,15 @@ import {
6
6
  loadIndexConfig,
7
7
  parseJson,
8
8
  queryIndex,
9
+ recordId,
9
10
  verifyIndex,
10
11
  } from '@razroo/iso-index';
12
+ import {
13
+ jobForgeCompanyRoleKey,
14
+ jobForgeUrlKey,
15
+ legacyCompanyRoleKey,
16
+ legacyUrlKey,
17
+ } from './jobforge-canon.mjs';
11
18
 
12
19
  export const INDEX_FILE = '.jobforge-index.json';
13
20
  export const INDEX_CONFIG_FILE = 'templates/index.json';
@@ -35,7 +42,7 @@ export function readJobForgeIndexConfig(projectDir = resolveProjectDir()) {
35
42
 
36
43
  export function buildJobForgeIndex(options = {}, projectDir = resolveProjectDir()) {
37
44
  const config = readJobForgeIndexConfig(projectDir);
38
- const index = buildIndex(config, { root: projectDir });
45
+ const index = canonicalizeJobForgeIndex(buildIndex(config, { root: projectDir }), projectDir);
39
46
  const out = options.out || jobForgeIndexPath(projectDir);
40
47
  if (options.write !== false) {
41
48
  writeFileSync(out, `${JSON.stringify(index, null, 2)}\n`, 'utf8');
@@ -90,3 +97,72 @@ export function jobForgeIndexSummary(projectDir = resolveProjectDir()) {
90
97
  configHash: index.configHash,
91
98
  };
92
99
  }
100
+
101
+ function canonicalizeJobForgeIndex(index, projectDir) {
102
+ const records = (index.records || []).map((record) => canonicalizeJobForgeIndexRecord(record, projectDir));
103
+ records.sort(compareRecords);
104
+ return {
105
+ ...index,
106
+ records,
107
+ stats: {
108
+ ...(index.stats || {}),
109
+ records: records.length,
110
+ },
111
+ };
112
+ }
113
+
114
+ function canonicalizeJobForgeIndexRecord(record, projectDir) {
115
+ const key = canonicalIndexKey(record, projectDir);
116
+ if (key === record.key) return record;
117
+ const updated = { ...record, key };
118
+ return { ...updated, id: recordId(updated) };
119
+ }
120
+
121
+ function canonicalIndexKey(record, projectDir) {
122
+ if (isCompanyRoleRecord(record)) {
123
+ const { company, role } = companyRoleFields(record);
124
+ if (company && role) return safeCompanyRoleKey(company, role, projectDir);
125
+ }
126
+ if (isUrlRecord(record)) {
127
+ const url = record.fields?.url;
128
+ if (url) return safeUrlKey(url, projectDir);
129
+ }
130
+ return record.key;
131
+ }
132
+
133
+ function isCompanyRoleRecord(record) {
134
+ return record.kind === 'jobforge.application' || record.kind === 'jobforge.tracker-addition';
135
+ }
136
+
137
+ function companyRoleFields(record) {
138
+ const fields = record.fields || {};
139
+ return {
140
+ company: fields.company || fields.Company,
141
+ role: fields.role || fields.Role,
142
+ };
143
+ }
144
+
145
+ function isUrlRecord(record) {
146
+ return record.kind === 'jobforge.report.url' || record.kind === 'jobforge.pipeline.url' || record.kind === 'jobforge.scan.url';
147
+ }
148
+
149
+ function safeCompanyRoleKey(company, role, projectDir) {
150
+ try {
151
+ return jobForgeCompanyRoleKey(company, role, projectDir);
152
+ } catch {
153
+ return legacyCompanyRoleKey(company, role);
154
+ }
155
+ }
156
+
157
+ function safeUrlKey(url, projectDir) {
158
+ try {
159
+ return jobForgeUrlKey(url, projectDir);
160
+ } catch {
161
+ return legacyUrlKey(url);
162
+ }
163
+ }
164
+
165
+ function compareRecords(a, b) {
166
+ return `${a.kind}\0${a.key}\0${a.source?.path || ''}\0${a.source?.line || ''}\0${a.id}`
167
+ .localeCompare(`${b.kind}\0${b.key}\0${b.source?.path || ''}\0${b.source?.line || ''}\0${b.id}`);
168
+ }
@@ -8,6 +8,16 @@ import {
8
8
  readLedger,
9
9
  verifyLedger,
10
10
  } from '@razroo/iso-ledger';
11
+ import {
12
+ jobForgeApplicationSubject,
13
+ jobForgeCompanyRoleKey,
14
+ jobForgeUrlKey,
15
+ legacyCompanyRoleKey,
16
+ legacySlugPart,
17
+ legacyUrlKey,
18
+ } from './jobforge-canon.mjs';
19
+
20
+ export { legacyCompanyRoleKey, legacyUrlKey };
11
21
 
12
22
  export const LEDGER_DIR = '.jobforge-ledger';
13
23
  export const LEDGER_FILE = 'events.jsonl';
@@ -89,7 +99,7 @@ export function recordTrackerMergeResult(addition, options = {}) {
89
99
 
90
100
  export function buildApplicationEvent(type, app, options = {}) {
91
101
  const projectDir = resolveProjectDir(options.projectDir);
92
- const key = companyRoleKey(app.company, app.role);
102
+ const key = companyRoleKey(app.company, app.role, projectDir);
93
103
  const sourceFile = options.sourceFile ? relativePath(projectDir, options.sourceFile) : '';
94
104
  const idempotencyParts = [
95
105
  options.idempotencyPrefix || type,
@@ -104,7 +114,7 @@ export function buildApplicationEvent(type, app, options = {}) {
104
114
  return {
105
115
  type,
106
116
  key,
107
- subject: applicationSubject(app.company, app.role),
117
+ subject: applicationSubject(app.company, app.role, projectDir),
108
118
  idempotencyKey: idempotencyParts.join(':'),
109
119
  data: compactObject({
110
120
  num: numberOrString(app.num),
@@ -128,7 +138,7 @@ export function buildApplicationEvent(type, app, options = {}) {
128
138
 
129
139
  export function buildPipelineEvent(item, options = {}) {
130
140
  const projectDir = resolveProjectDir(options.projectDir);
131
- const key = item.url ? urlKey(item.url) : `pipeline:${item.lineNumber || 'unknown'}`;
141
+ const key = item.url ? urlKey(item.url, projectDir) : `pipeline:${item.lineNumber || 'unknown'}`;
132
142
  const sourceFile = options.sourceFile ? relativePath(projectDir, options.sourceFile) : 'data/pipeline.md';
133
143
  const state = item.checked ? 'processed' : 'pending';
134
144
 
@@ -154,25 +164,33 @@ export function buildPipelineEvent(item, options = {}) {
154
164
  };
155
165
  }
156
166
 
157
- export function companyRoleKey(company, role) {
158
- return `company-role:${slugPart(company)}:${slugPart(role)}`;
167
+ export function companyRoleKey(company, role, projectDir = resolveProjectDir()) {
168
+ try {
169
+ return jobForgeCompanyRoleKey(company, role, projectDir);
170
+ } catch {
171
+ return legacyCompanyRoleKey(company, role);
172
+ }
159
173
  }
160
174
 
161
- export function applicationSubject(company, role) {
162
- return `application:${slugPart(company)}:${slugPart(role)}`;
175
+ export function applicationSubject(company, role, projectDir = resolveProjectDir()) {
176
+ try {
177
+ return jobForgeApplicationSubject(company, role, projectDir);
178
+ } catch {
179
+ const key = legacyCompanyRoleKey(company, role).slice('company-role:'.length);
180
+ return `application:${key}`;
181
+ }
163
182
  }
164
183
 
165
- export function urlKey(url) {
166
- return `url:${String(url || '').trim()}`;
184
+ export function urlKey(url, projectDir = resolveProjectDir()) {
185
+ try {
186
+ return jobForgeUrlKey(url, projectDir);
187
+ } catch {
188
+ return legacyUrlKey(url);
189
+ }
167
190
  }
168
191
 
169
192
  export function slugPart(value) {
170
- const slug = String(value || 'unknown')
171
- .toLowerCase()
172
- .replace(/&/g, ' and ')
173
- .replace(/[^a-z0-9]+/g, '-')
174
- .replace(/^-+|-+$/g, '');
175
- return slug || 'unknown';
193
+ return legacySlugPart(value);
176
194
  }
177
195
 
178
196
  function relativePath(projectDir, value) {
@@ -0,0 +1,33 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import {
4
+ loadMigrationConfig,
5
+ parseJson,
6
+ runMigrations,
7
+ } from '@razroo/iso-migrate';
8
+
9
+ export const MIGRATION_CONFIG_FILE = 'templates/migrations.json';
10
+
11
+ export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
12
+ return projectDir;
13
+ }
14
+
15
+ export function jobForgeMigrationConfigPath(projectDir = resolveProjectDir()) {
16
+ return process.env.JOB_FORGE_MIGRATIONS_CONFIG || join(projectDir, MIGRATION_CONFIG_FILE);
17
+ }
18
+
19
+ export function migrationConfigExists(projectDir = resolveProjectDir()) {
20
+ return existsSync(jobForgeMigrationConfigPath(projectDir));
21
+ }
22
+
23
+ export function readJobForgeMigrationConfig(projectDir = resolveProjectDir()) {
24
+ const path = jobForgeMigrationConfigPath(projectDir);
25
+ return loadMigrationConfig(parseJson(readFileSync(path, 'utf8'), path));
26
+ }
27
+
28
+ export function runJobForgeMigrations(options = {}, projectDir = resolveProjectDir()) {
29
+ return runMigrations(readJobForgeMigrationConfig(projectDir), {
30
+ root: options.root || projectDir,
31
+ dryRun: options.dryRun,
32
+ });
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-forge",
3
- "version": "2.14.25",
3
+ "version": "2.14.27",
4
4
  "description": "AI-powered job search pipeline built on opencode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,6 +55,14 @@
55
55
  "index:has": "node bin/job-forge.mjs index:has",
56
56
  "index:verify": "node bin/job-forge.mjs index:verify",
57
57
  "index:explain": "node bin/job-forge.mjs index:explain",
58
+ "canon:normalize": "node bin/job-forge.mjs canon:normalize",
59
+ "canon:key": "node bin/job-forge.mjs canon:key",
60
+ "canon:compare": "node bin/job-forge.mjs canon:compare",
61
+ "canon:explain": "node bin/job-forge.mjs canon:explain",
62
+ "migrate:plan": "node bin/job-forge.mjs migrate:plan",
63
+ "migrate:apply": "node bin/job-forge.mjs migrate:apply",
64
+ "migrate:check": "node bin/job-forge.mjs migrate:check",
65
+ "migrate:explain": "node bin/job-forge.mjs migrate:explain",
58
66
  "plan": "iso plan .",
59
67
  "lint:agentmd": "agentmd lint iso/instructions.md",
60
68
  "lint:modes": "isolint lint modes/",
@@ -123,11 +131,13 @@
123
131
  "dependencies": {
124
132
  "@razroo/iso-capabilities": "^0.1.0",
125
133
  "@razroo/iso-cache": "^0.1.0",
134
+ "@razroo/iso-canon": "^0.1.0",
126
135
  "@razroo/iso-context": "^0.1.0",
127
136
  "@razroo/iso-contract": "^0.1.0",
128
137
  "@razroo/iso-guard": "^0.1.0",
129
138
  "@razroo/iso-index": "^0.1.0",
130
139
  "@razroo/iso-ledger": "^0.1.0",
140
+ "@razroo/iso-migrate": "^0.1.0",
131
141
  "@razroo/iso-orchestrator": "^0.1.0",
132
142
  "@razroo/iso-trace": "^0.4.0",
133
143
  "playwright": "^1.58.1"
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { relative } from 'path';
4
+ import {
5
+ formatCanonResult,
6
+ formatCompareResult,
7
+ formatConfigSummary,
8
+ } from '@razroo/iso-canon';
9
+ import { PROJECT_DIR } from '../tracker-lib.mjs';
10
+ import {
11
+ canonicalizeJobForgeEntity,
12
+ compareJobForgeCanon,
13
+ jobForgeCanonConfigPath,
14
+ jobForgeCanonProfile,
15
+ } from '../lib/jobforge-canon.mjs';
16
+
17
+ const USAGE = `job-forge canon - deterministic identity keys for JobForge
18
+
19
+ Usage:
20
+ job-forge canon:normalize <url|company|role> <value> [--json]
21
+ job-forge canon:normalize company-role --company <name> --role <title> [--json]
22
+ job-forge canon:key <url|company|role> <value> [--json]
23
+ job-forge canon:key company-role --company <name> --role <title> [--json]
24
+ job-forge canon:compare <url|company|role> <left> <right> [--json]
25
+ job-forge canon:compare company-role --left-company <name> --left-role <title> --right-company <name> --right-role <title> [--json]
26
+ job-forge canon:explain [--json]
27
+ job-forge canon:path
28
+
29
+ The policy is templates/canon.json. These commands are local, model-free, and
30
+ use the same keys that JobForge ledger/cache helpers use internally.`;
31
+
32
+ const [cmd = 'help', ...rawArgs] = process.argv.slice(2);
33
+ const opts = parseArgs(rawArgs);
34
+
35
+ if (opts.help || cmd === 'help' || cmd === '--help' || cmd === '-h') {
36
+ console.log(USAGE);
37
+ process.exit(0);
38
+ }
39
+
40
+ try {
41
+ if (cmd === 'path') {
42
+ console.log(jobForgeCanonConfigPath(PROJECT_DIR));
43
+ } else if (cmd === 'normalize') {
44
+ normalize(opts, false);
45
+ } else if (cmd === 'key') {
46
+ normalize(opts, true);
47
+ } else if (cmd === 'compare') {
48
+ compare(opts);
49
+ } else if (cmd === 'explain') {
50
+ explain(opts);
51
+ } else {
52
+ console.error(`unknown canon command "${cmd}"\n`);
53
+ console.error(USAGE);
54
+ process.exit(2);
55
+ }
56
+ } catch (error) {
57
+ console.error(error instanceof Error ? error.message : String(error));
58
+ process.exit(1);
59
+ }
60
+
61
+ function parseArgs(args) {
62
+ const opts = {
63
+ json: false,
64
+ help: false,
65
+ values: [],
66
+ };
67
+
68
+ for (let i = 0; i < args.length; i++) {
69
+ const arg = args[i];
70
+ if (arg === '--json') {
71
+ opts.json = true;
72
+ } else if (arg === '--company') {
73
+ opts.company = valueAfter(args, ++i, '--company');
74
+ } else if (arg.startsWith('--company=')) {
75
+ opts.company = arg.slice('--company='.length);
76
+ } else if (arg === '--role') {
77
+ opts.role = valueAfter(args, ++i, '--role');
78
+ } else if (arg.startsWith('--role=')) {
79
+ opts.role = arg.slice('--role='.length);
80
+ } else if (arg === '--left-company') {
81
+ opts.leftCompany = valueAfter(args, ++i, '--left-company');
82
+ } else if (arg.startsWith('--left-company=')) {
83
+ opts.leftCompany = arg.slice('--left-company='.length);
84
+ } else if (arg === '--left-role') {
85
+ opts.leftRole = valueAfter(args, ++i, '--left-role');
86
+ } else if (arg.startsWith('--left-role=')) {
87
+ opts.leftRole = arg.slice('--left-role='.length);
88
+ } else if (arg === '--right-company') {
89
+ opts.rightCompany = valueAfter(args, ++i, '--right-company');
90
+ } else if (arg.startsWith('--right-company=')) {
91
+ opts.rightCompany = arg.slice('--right-company='.length);
92
+ } else if (arg === '--right-role') {
93
+ opts.rightRole = valueAfter(args, ++i, '--right-role');
94
+ } else if (arg.startsWith('--right-role=')) {
95
+ opts.rightRole = arg.slice('--right-role='.length);
96
+ } else if (arg === '--help' || arg === '-h') {
97
+ opts.help = true;
98
+ } else if (arg.startsWith('--')) {
99
+ throw new Error(`unknown flag "${arg}"`);
100
+ } else {
101
+ opts.values.push(arg);
102
+ }
103
+ }
104
+
105
+ return opts;
106
+ }
107
+
108
+ function normalize(opts, keyOnly) {
109
+ const type = parseType(opts.values.shift(), keyOnly ? 'key' : 'normalize');
110
+ const input = normalizeInput(type, opts);
111
+ const result = canonicalizeJobForgeEntity(type, input, PROJECT_DIR);
112
+ if (opts.json) {
113
+ console.log(JSON.stringify(result, null, 2));
114
+ } else if (keyOnly) {
115
+ console.log(result.key);
116
+ } else {
117
+ console.log(formatCanonResult(result));
118
+ }
119
+ }
120
+
121
+ function compare(opts) {
122
+ const type = parseType(opts.values.shift(), 'compare');
123
+ const [left, right] = compareInputs(type, opts);
124
+ const result = compareJobForgeCanon(type, left, right, PROJECT_DIR);
125
+ if (opts.json) {
126
+ console.log(JSON.stringify(result, null, 2));
127
+ return;
128
+ }
129
+ console.log(formatCompareResult(result));
130
+ }
131
+
132
+ function explain(opts) {
133
+ const profile = jobForgeCanonProfile(PROJECT_DIR);
134
+ if (opts.json) {
135
+ console.log(JSON.stringify(profile, null, 2));
136
+ return;
137
+ }
138
+ console.log(`config: ${relativePath(jobForgeCanonConfigPath(PROJECT_DIR))}`);
139
+ console.log(formatConfigSummary({ version: 1, profiles: [profile] }));
140
+ }
141
+
142
+ function normalizeInput(type, opts) {
143
+ if (type === 'company-role') {
144
+ if (!opts.company || !opts.role) throw new Error('company-role requires --company and --role');
145
+ return { company: opts.company, role: opts.role };
146
+ }
147
+ if (opts.values.length !== 1) throw new Error(`${type}: provide exactly one value; quote values containing spaces`);
148
+ return opts.values[0];
149
+ }
150
+
151
+ function compareInputs(type, opts) {
152
+ if (type === 'company-role') {
153
+ if (!opts.leftCompany || !opts.leftRole || !opts.rightCompany || !opts.rightRole) {
154
+ throw new Error('company-role compare requires --left-company, --left-role, --right-company, and --right-role');
155
+ }
156
+ return [
157
+ { company: opts.leftCompany, role: opts.leftRole },
158
+ { company: opts.rightCompany, role: opts.rightRole },
159
+ ];
160
+ }
161
+ if (opts.values.length !== 2) throw new Error(`${type}: provide exactly two values; quote values containing spaces`);
162
+ return [opts.values[0], opts.values[1]];
163
+ }
164
+
165
+ function parseType(value, command) {
166
+ if (value === 'url' || value === 'company' || value === 'role' || value === 'company-role') return value;
167
+ throw new Error(`${command}: expected type url, company, role, or company-role`);
168
+ }
169
+
170
+ function valueAfter(values, index, flag) {
171
+ const value = values[index];
172
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
173
+ return value;
174
+ }
175
+
176
+ function relativePath(path) {
177
+ return relative(PROJECT_DIR, path) || '.';
178
+ }
@@ -16,6 +16,8 @@ import {
16
16
  jobForgeLedgerPath,
17
17
  jobForgeLedgerSummary,
18
18
  ledgerExists,
19
+ legacyCompanyRoleKey,
20
+ legacyUrlKey,
19
21
  readJobForgeLedger,
20
22
  urlKey,
21
23
  verifyJobForgeLedger,
@@ -201,8 +203,9 @@ function verify(opts) {
201
203
  }
202
204
 
203
205
  function has(opts) {
204
- const filters = queryFilters(opts);
205
- const events = queryEvents(readJobForgeLedger(PROJECT_DIR), filters);
206
+ const filters = queryFilterCandidates(opts);
207
+ const ledger = readJobForgeLedger(PROJECT_DIR);
208
+ const events = uniqueEvents(filters.flatMap((filter) => queryEvents(ledger, filter)));
206
209
  if (opts.json) {
207
210
  console.log(JSON.stringify({ match: events.length > 0, count: events.length, filters }, null, 2));
208
211
  } else if (events.length > 0) {
@@ -237,6 +240,28 @@ function queryFilters(opts) {
237
240
  return filters;
238
241
  }
239
242
 
243
+ function queryFilterCandidates(opts) {
244
+ const primary = queryFilters(opts);
245
+ const candidates = [primary];
246
+ const legacy = { ...primary };
247
+ if (opts.url) legacy.key = legacyUrlKey(opts.url);
248
+ if (opts.company || opts.role) legacy.key = legacyCompanyRoleKey(opts.company, opts.role);
249
+ if (legacy.key && legacy.key !== primary.key) candidates.push(legacy);
250
+ return candidates;
251
+ }
252
+
253
+ function uniqueEvents(events) {
254
+ const seen = new Set();
255
+ const out = [];
256
+ for (const event of events) {
257
+ const id = event.id || event.idempotencyKey || JSON.stringify(event);
258
+ if (seen.has(id)) continue;
259
+ seen.add(id);
260
+ out.push(event);
261
+ }
262
+ return out;
263
+ }
264
+
240
265
  function collectProjectEvents() {
241
266
  const events = [];
242
267
  const { entries } = readAllEntries();
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { relative } from 'path';
4
+ import {
5
+ formatConfigSummary,
6
+ formatMigrationResult,
7
+ } from '@razroo/iso-migrate';
8
+ import { PROJECT_DIR } from '../tracker-lib.mjs';
9
+ import {
10
+ jobForgeMigrationConfigPath,
11
+ readJobForgeMigrationConfig,
12
+ runJobForgeMigrations,
13
+ } from '../lib/jobforge-migrate.mjs';
14
+
15
+ const USAGE = `job-forge migrate - deterministic consumer-project migrations
16
+
17
+ Usage:
18
+ job-forge migrate:plan [--json]
19
+ job-forge migrate:apply [--json]
20
+ job-forge migrate:check [--json]
21
+ job-forge migrate:explain [--json]
22
+ job-forge migrate:path
23
+
24
+ The policy is templates/migrations.json. Sync applies these migrations
25
+ automatically unless JOB_FORGE_SKIP_MIGRATIONS=1 is set.`;
26
+
27
+ const [cmd = 'help', ...rawArgs] = process.argv.slice(2);
28
+ const opts = parseArgs(rawArgs);
29
+
30
+ if (opts.help || cmd === 'help' || cmd === '--help' || cmd === '-h') {
31
+ console.log(USAGE);
32
+ process.exit(0);
33
+ }
34
+
35
+ try {
36
+ if (cmd === 'path') {
37
+ console.log(jobForgeMigrationConfigPath(PROJECT_DIR));
38
+ } else if (cmd === 'plan') {
39
+ run('plan', opts);
40
+ } else if (cmd === 'apply') {
41
+ run('apply', opts);
42
+ } else if (cmd === 'check') {
43
+ const result = run('check', opts);
44
+ process.exit(result.changed ? 1 : 0);
45
+ } else if (cmd === 'explain') {
46
+ explain(opts);
47
+ } else {
48
+ console.error(`unknown migrate command "${cmd}"\n`);
49
+ console.error(USAGE);
50
+ process.exit(2);
51
+ }
52
+ } catch (error) {
53
+ console.error(error instanceof Error ? error.message : String(error));
54
+ process.exit(1);
55
+ }
56
+
57
+ function parseArgs(args) {
58
+ const opts = { json: false, help: false };
59
+ for (const arg of args) {
60
+ if (arg === '--json') opts.json = true;
61
+ else if (arg === '--help' || arg === '-h') opts.help = true;
62
+ else throw new Error(`unknown flag "${arg}"`);
63
+ }
64
+ return opts;
65
+ }
66
+
67
+ function run(mode, opts) {
68
+ const result = runJobForgeMigrations({ dryRun: mode !== 'apply' }, PROJECT_DIR);
69
+ if (opts.json) {
70
+ console.log(JSON.stringify(result, null, 2));
71
+ } else {
72
+ console.log(formatMigrationResult(result, mode));
73
+ }
74
+ return result;
75
+ }
76
+
77
+ function explain(opts) {
78
+ const config = readJobForgeMigrationConfig(PROJECT_DIR);
79
+ if (opts.json) {
80
+ console.log(JSON.stringify(config, null, 2));
81
+ return;
82
+ }
83
+ console.log(`config: ${relativePath(jobForgeMigrationConfigPath(PROJECT_DIR))}`);
84
+ console.log(formatConfigSummary(config));
85
+ }
86
+
87
+ function relativePath(path) {
88
+ return relative(PROJECT_DIR, path) || '.';
89
+ }