@gafj/gafj 0.1.1

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.
Files changed (121) hide show
  1. package/README.md +99 -0
  2. package/bin/cli.js +628 -0
  3. package/bridge/byo_key.js +50 -0
  4. package/bridge/credits.js +72 -0
  5. package/bridge/paste.js +44 -0
  6. package/bridge/providers/anthropic.js +16 -0
  7. package/bridge/providers/gemini.js +15 -0
  8. package/bridge/providers/index.js +39 -0
  9. package/bridge/providers/openai_compatible.js +17 -0
  10. package/core/extract.js +55 -0
  11. package/core/generators/excerpt.js +64 -0
  12. package/core/generators/packet.js +197 -0
  13. package/core/generators/serialize.js +56 -0
  14. package/core/generators/slice.js +61 -0
  15. package/core/house_rules.js +49 -0
  16. package/core/kb_import.js +257 -0
  17. package/core/migrations/002-profile.sql +3 -0
  18. package/core/migrations/003-discover.sql +49 -0
  19. package/core/parsers.js +79 -0
  20. package/core/practice.js +75 -0
  21. package/core/provenance.js +90 -0
  22. package/core/qr.js +185 -0
  23. package/core/rules/categories.md +5 -0
  24. package/core/rules/deep_answers.md +7 -0
  25. package/core/rules/discover.md +30 -0
  26. package/core/rules/extract.md +31 -0
  27. package/core/rules/followup.md +9 -0
  28. package/core/rules/index.js +32 -0
  29. package/core/rules/onepager.md +15 -0
  30. package/core/rules/practice.md +10 -0
  31. package/core/rules/prep.md +47 -0
  32. package/core/rules/resume.md +49 -0
  33. package/core/rules/rubric.md +120 -0
  34. package/core/schema.sql +342 -0
  35. package/core/schemas/categories.js +16 -0
  36. package/core/schemas/cover.js +10 -0
  37. package/core/schemas/deep_answers.js +16 -0
  38. package/core/schemas/discover.js +19 -0
  39. package/core/schemas/extract.js +32 -0
  40. package/core/schemas/followup.js +10 -0
  41. package/core/schemas/index.js +32 -0
  42. package/core/schemas/lang.js +111 -0
  43. package/core/schemas/onepager.js +26 -0
  44. package/core/schemas/practice.js +18 -0
  45. package/core/schemas/prep.js +15 -0
  46. package/core/schemas/resume.js +25 -0
  47. package/core/scoring.js +586 -0
  48. package/core/text.js +98 -0
  49. package/core/tracker_map.js +94 -0
  50. package/core/ulid.js +38 -0
  51. package/core/validators/highlight_body_match.js +49 -0
  52. package/core/validators/index.js +43 -0
  53. package/core/validators/metric_pairing.js +52 -0
  54. package/core/validators/no_confabulated_history.js +63 -0
  55. package/core/validators/no_dashes.js +31 -0
  56. package/core/validators/no_derived_tenure.js +60 -0
  57. package/core/validators/style_config.js +18 -0
  58. package/core/validators/verb_ladder.js +51 -0
  59. package/core/verbs.js +43 -0
  60. package/http/api.js +260 -0
  61. package/http/guard.js +106 -0
  62. package/http/headers.js +17 -0
  63. package/http/hosted.js +273 -0
  64. package/http/identity.js +70 -0
  65. package/http/server.js +136 -0
  66. package/http/sse.js +41 -0
  67. package/mcp/server.js +25 -0
  68. package/mcp/tools.js +237 -0
  69. package/package.json +59 -0
  70. package/store/backup.js +63 -0
  71. package/store/backup_dir.js +60 -0
  72. package/store/config.js +32 -0
  73. package/store/context.js +74 -0
  74. package/store/db.js +37 -0
  75. package/store/discover.js +120 -0
  76. package/store/events.js +28 -0
  77. package/store/export_xlsx.js +62 -0
  78. package/store/home.js +36 -0
  79. package/store/import_kb.js +88 -0
  80. package/store/import_tracker.js +195 -0
  81. package/store/ingest.js +140 -0
  82. package/store/kb.js +202 -0
  83. package/store/log.js +88 -0
  84. package/store/migrate.js +55 -0
  85. package/store/onboarding.js +267 -0
  86. package/store/packets.js +110 -0
  87. package/store/practice.js +52 -0
  88. package/store/proposals.js +119 -0
  89. package/store/reads.js +110 -0
  90. package/store/render.js +111 -0
  91. package/store/reply.js +19 -0
  92. package/store/review.js +64 -0
  93. package/store/scoring.js +134 -0
  94. package/store/settings.js +142 -0
  95. package/store/snapshot.js +88 -0
  96. package/store/transitions.js +391 -0
  97. package/store/tx.js +71 -0
  98. package/store/tz.js +24 -0
  99. package/ui/app.css +137 -0
  100. package/ui/app.js +75 -0
  101. package/ui/fonts/plexmono-400.woff2 +0 -0
  102. package/ui/fonts/plexmono-500.woff2 +0 -0
  103. package/ui/fonts/plexmono-600.woff2 +0 -0
  104. package/ui/fonts/spacegrotesk-var.woff2 +0 -0
  105. package/ui/hosted/signin.css +8 -0
  106. package/ui/hosted/signin.html +30 -0
  107. package/ui/hosted/signin.js +28 -0
  108. package/ui/icon-192.png +0 -0
  109. package/ui/icon-512.png +0 -0
  110. package/ui/index.html +20 -0
  111. package/ui/lib.js +92 -0
  112. package/ui/manifest.webmanifest +18 -0
  113. package/ui/screens/application.js +73 -0
  114. package/ui/screens/dashboard.js +37 -0
  115. package/ui/screens/document.js +64 -0
  116. package/ui/screens/interview.js +85 -0
  117. package/ui/screens/kb.js +142 -0
  118. package/ui/screens/packet.js +122 -0
  119. package/ui/screens/postings.js +92 -0
  120. package/ui/screens/settings.js +115 -0
  121. package/ui/vendor/preact.mjs +1 -0
package/bin/cli.js ADDED
@@ -0,0 +1,628 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * gafj command line. Phases 1 and 1b: store, import, review, lint, ingest.
6
+ * Later phases add packet, mcp, serve, and the rest. The Node floor is
7
+ * enforced here because "engines" in package.json is advisory. Every
8
+ * mutating command logs events with actor cli and the command name.
9
+ */
10
+
11
+ const NODE_FLOOR = 24;
12
+ const major = Number(process.versions.node.split(".")[0]);
13
+ if (major < NODE_FLOOR && !process.env.GAFJ_ALLOW_OLD_NODE) {
14
+ process.stderr.write(`gafj needs Node ${NODE_FLOOR} or newer; found ${process.version}.\n`);
15
+ process.exit(2);
16
+ }
17
+
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+ const { resolveHome } = require("../store/home");
21
+ const { open } = require("../store/db");
22
+ const { migrate } = require("../store/migrate");
23
+ const { readConfig, writeConfig } = require("../store/config");
24
+ const { ulid } = require("../core/ulid");
25
+
26
+ const COMMANDS = {
27
+ migrate: "apply pending schema migrations to the store",
28
+ import: "import: --kb <dir> [--tracker <xlsx>] [--rounds <json>] [--fixtures <dir>] [--profile <json>] [--name <candidate name>]",
29
+ snapshot: "print the campaign snapshot",
30
+ review: "review queue: review [list [--record <id>]] | review confirm <record> <metric|verb|claim> <value|*> | review drop ... | review edit <record> <dim> <value> --set key=val",
31
+ lint: "run the validators on plain text: --kind <resume|prep|email|...> --file <text file> [--application <company>]",
32
+ ingest: "ingest a generated document through the door: --file <json or text> (--run <id> | --op <op> --interview-id <id> | --posting-id <id>) [--route paste|manual]",
33
+ export: "write a dated tracker xlsx: --out <dir>",
34
+ backup: "write a consistent copy of the store: [--out <dir>]",
35
+ restore: "restore a backup: <file> --into <home dir> [--into-live --yes]",
36
+ "serve-hosted": "the hosted server (plan W): GAFJ_HOSTED_ROOT, GAFJ_HOST, PORT, GAFJ_FIREBASE_PROJECT_ID, GAFJ_FIREBASE_API_KEY, GAFJ_RELAY_URL, GAFJ_RELAY_SERVICE_KEY",
37
+ packet: "write a paste packet: packet <resume|cover|prep|onepager|deep_answers|followup|practice> (--interview-id <id> | --posting-id <id>) [--out <dir>], or packet discover",
38
+ "score-freeze": "score a posting: score-freeze --posting-id <id> [--from-scan] [--categories <json>] [--scores <json>] [--propose-only]",
39
+ render: "write a passed document as Markdown: render --document <id> [--out <dir>]",
40
+ list: "list ids: list postings|applications|interviews|documents|runs",
41
+ mcp: "run the stdio MCP server for Claude Desktop or Claude Code (stdout is the wire)",
42
+ serve: "run the local UI server on 127.0.0.1: serve [--port <n>] [--force] [--no-open]",
43
+ url: "print a fresh launch URL for the running server (and open the browser): url [--no-open] [--host <allowed host>] [--qr]",
44
+ "allow-host": "let a local HTTPS proxy front the server for your phone: allow-host <name.tail1234.ts.net> | allow-host --remove <name>",
45
+ "rotate-token": "rotate the session token; every open browser session ends",
46
+ "print-mcp-config": "print the Claude Desktop config snippet and the Claude Code command for this machine",
47
+ "install-startup": "register gafj serve to start at login: install-startup [--print] [--remove]",
48
+ init: "create a candidate and open an onboarding batch: init --name <name>",
49
+ onboard: "onboarding: onboard add <files...> | onboard status | onboard packet --source <id> [--out <dir>] | onboard ingest --run <id> --file <json> | onboard dedup | onboard merge <group> [--keep <pending id>] | onboard style",
50
+ };
51
+
52
+ function usage(code) {
53
+ const out = ["usage: gafj <command> [options]", ""];
54
+ for (const [name, help] of Object.entries(COMMANDS)) out.push(` ${name.padEnd(14)} ${help}`);
55
+ out.push("", "GAFJ_HOME overrides the data folder. GAFJ_FIXTURES_PRIVATE points at the private calibration set.");
56
+ (code ? process.stderr : process.stdout).write(out.join("\n") + "\n");
57
+ process.exit(code);
58
+ }
59
+
60
+ function parseArgs(argv) {
61
+ const opts = { _: [] };
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const a = argv[i];
64
+ if (a.startsWith("--")) {
65
+ const key = a.slice(2);
66
+ const next = argv[i + 1];
67
+ if (next === undefined || next.startsWith("--")) opts[key] = true;
68
+ else if (key === "set") { (opts.set = opts.set || []).push(next); i++; }
69
+ else { opts[key] = next; i++; }
70
+ } else opts._.push(a);
71
+ }
72
+ return opts;
73
+ }
74
+
75
+ const nowIso = () => new Date().toISOString();
76
+ const ACTOR = (ref) => ({ type: "cli", ref });
77
+
78
+ function openStore(home) {
79
+ const db = open(path.join(home.dir, "gafj.db"));
80
+ migrate(db);
81
+ return db;
82
+ }
83
+
84
+ function activeCandidate(home, cfg, db, name) {
85
+ if (cfg.candidate_id) return cfg.candidate_id;
86
+ const id = ulid(Date.now());
87
+ db.prepare("INSERT INTO candidate (id, name, created_at) VALUES (?, ?, ?)").run(id, name || "Candidate", nowIso());
88
+ cfg.candidate_id = id;
89
+ writeConfig(home.dir, cfg);
90
+ return id;
91
+ }
92
+
93
+ function readFixtureTexts(dir) {
94
+ if (!dir || !fs.existsSync(dir)) return [];
95
+ return fs.readdirSync(dir).filter((f) => /\.(txt|md)$/i.test(f) && /resume-live|prep-handbuilt/i.test(f))
96
+ .map((f) => fs.readFileSync(path.join(dir, f), "utf8"));
97
+ }
98
+
99
+ /** A QR code in the terminal so a phone can scan the launch link (byte mode, error level L, versions 1 to 10). */
100
+ function qrText(text) {
101
+ try { return require("../core/qr").render(text); } catch (e) { return `(qr unavailable: ${e.message})`; }
102
+ }
103
+
104
+ function openBrowser(url) {
105
+ const { spawn } = require("child_process");
106
+ try {
107
+ const p = process.platform === "win32" ? spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" })
108
+ : process.platform === "darwin" ? spawn("open", [url], { detached: true, stdio: "ignore" })
109
+ : spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
110
+ p.on("error", () => {});
111
+ p.unref();
112
+ } catch (_) { /* no browser here */ }
113
+ }
114
+
115
+ function findApplication(db, candidate_id, company) {
116
+ if (!company) return null;
117
+ const row = db.prepare(`SELECT a.id FROM application a JOIN posting p ON p.id = a.posting_id WHERE p.candidate_id = ? AND lower(p.company) LIKE lower(?) ORDER BY a.applied_at DESC LIMIT 1`)
118
+ .get(candidate_id, company + "%");
119
+ if (!row) throw new Error(`no application for a company starting with "${company}"`);
120
+ return row.id;
121
+ }
122
+
123
+ function printFindings(out, r) {
124
+ for (const b of r.blocks) out(` BLOCK ${b.rule.padEnd(24)} ${b.path || ""} "${b.span}" ${b.hint}${b.nearby && b.nearby.length ? " nearby: " + b.nearby.map((n) => n.id).join(", ") : ""}`);
125
+ for (const w of r.warnings) out(` warn ${w.rule.padEnd(24)} ${w.path || ""} "${w.span}" ${w.hint}`);
126
+ }
127
+
128
+ async function main(argv) {
129
+ const cmd = argv[0];
130
+ if (!cmd || cmd === "--help" || cmd === "-h") usage(0);
131
+ if (!(cmd in COMMANDS)) { process.stderr.write(`unknown command: ${cmd}\n`); usage(2); }
132
+ const opts = parseArgs(argv.slice(1));
133
+ const home = resolveHome(process.env);
134
+ if (home.warning) process.stderr.write(`warning: ${home.warning}\n`);
135
+ const cfg = readConfig(home.dir);
136
+ const out = (s) => process.stdout.write(s + "\n");
137
+
138
+ if (cmd === "migrate") {
139
+ const db = openStore(home);
140
+ try { out(`store ${path.join(home.dir, "gafj.db")} at schema version ${migrate(db)}`); } finally { db.close(); }
141
+ return;
142
+ }
143
+
144
+ if (cmd === "import") {
145
+ const { importKb, setProfile } = require("../store/import_kb");
146
+ const { readTracker, importTracker } = require("../store/import_tracker");
147
+ const db = openStore(home);
148
+ try {
149
+ const now = nowIso();
150
+ const candidate_id = activeCandidate(home, cfg, db, opts.name);
151
+ if (opts.profile) {
152
+ const profile = JSON.parse(fs.readFileSync(opts.profile, "utf8"));
153
+ setProfile(db, { candidate_id, profile, now, actor: ACTOR("import") });
154
+ out(`profile stored for candidate ${candidate_id}`);
155
+ }
156
+ if (opts.kb) {
157
+ const accs = JSON.parse(fs.readFileSync(path.join(opts.kb, "accomplishments.json"), "utf8"));
158
+ const vars = JSON.parse(fs.readFileSync(path.join(opts.kb, "bullet-variations.json"), "utf8"));
159
+ const fixturesDir = opts.fixtures || process.env.GAFJ_FIXTURES_PRIVATE;
160
+ const fixtureTexts = readFixtureTexts(fixturesDir);
161
+ const name = db.prepare("SELECT name FROM candidate WHERE id = ?").get(candidate_id).name;
162
+ const kb = importKb(db, { candidate: { id: candidate_id, name }, accomplishments: accs.accomplishments || accs,
163
+ variations: vars.variations || vars, fixtureTexts, now, actor: ACTOR("import") });
164
+ out(`knowledge base: ${kb.inserted} inserted, ${kb.updated} updated, ${kb.review.length} dimensions to review (${fixtureTexts.length} fixture texts applied)`);
165
+ }
166
+ if (opts.tracker) {
167
+ const tracker = await readTracker(opts.tracker);
168
+ const manifest = opts.rounds ? JSON.parse(fs.readFileSync(opts.rounds, "utf8")) : {};
169
+ const postingTexts = {};
170
+ for (const o of manifest.overrides || []) {
171
+ if (o.posting_text_file) {
172
+ const p = path.isAbsolute(o.posting_text_file) ? o.posting_text_file : path.join(path.dirname(opts.rounds), o.posting_text_file);
173
+ postingTexts[o.posting_text_file] = fs.readFileSync(p, "utf8");
174
+ }
175
+ }
176
+ const tr = importTracker(db, { candidate_id, tracker, manifest, postingTexts, now, actor: ACTOR("import") });
177
+ out(`tracker: ${tr.postings} postings, ${tr.applications} applications, ${tr.interviews} interviews, ${tr.people} people, ${tr.communications} communications`);
178
+ for (const w of tr.warnings) out(` warning: ${w}`);
179
+ }
180
+ if (!opts.kb && !opts.tracker && !opts.profile) out("nothing to import; pass --kb, --tracker, or --profile");
181
+ else out(`run "gafj review" to work the review queue`);
182
+ } finally { db.close(); }
183
+ return;
184
+ }
185
+
186
+ if (cmd === "snapshot") {
187
+ const { snapshot } = require("../store/snapshot");
188
+ const db = openStore(home);
189
+ try {
190
+ if (!cfg.candidate_id) { out("no candidate yet; run import first"); return; }
191
+ const s = snapshot(db, { candidate_id: cfg.candidate_id, now: nowIso(), thresholds: cfg.thresholds });
192
+ out(`NEEDS YOU NOW`); for (const n of s.needs_you) out(` ${n.text}`);
193
+ out(`UPCOMING`); for (const u of s.upcoming) out(` ${u.scheduled_at} ${u.tz} ${u.company} ${u.round} ${u.format} ${u.people.join(", ")}`);
194
+ out(`ACTIVE (${s.active.length})`); for (const a of s.active) out(` ${a.company.padEnd(22)} ${a.title.slice(0, 40).padEnd(40)} ${a.status.padEnd(9)} fit ${a.fit ?? "-"}`);
195
+ out(`AGING`); for (const a of s.aging) out(` ${a.text}`);
196
+ out(`CLOSED (${s.closed.length})`); for (const c of s.closed) out(` ${c.company.padEnd(22)} ${c.title.slice(0, 40).padEnd(40)} ${c.status}`);
197
+ } finally { db.close(); }
198
+ return;
199
+ }
200
+
201
+ if (cmd === "review") {
202
+ const { listReview, mutate } = require("../store/review");
203
+ const db = openStore(home);
204
+ try {
205
+ const candidate_id = cfg.candidate_id;
206
+ if (!candidate_id) { out("no candidate yet; run import first"); return; }
207
+ const sub = opts._[0] || "list";
208
+ if (sub === "list") {
209
+ const items = listReview(db, candidate_id, { record: opts.record });
210
+ out(`${items.length} dimensions to review`);
211
+ let last = null;
212
+ for (const it of items) {
213
+ if (it.id !== last) { out(`\n${it.id} ${it.company}: ${it.title}`); last = it.id; }
214
+ out(` ${it.dimension.padEnd(7)} ${String(it.value).padEnd(28)} ${it.detail || ""}`);
215
+ }
216
+ out(`\nconfirm one: gafj review confirm <record id> <metric|verb|claim> "<value>"\nconfirm all on a record: gafj review confirm <record id> metric *`);
217
+ return;
218
+ }
219
+ const [record, dimension, value] = opts._.slice(1);
220
+ if (!record || !dimension || value === undefined) { process.stderr.write("review <confirm|drop|edit> <record> <metric|verb|claim> <value|*>\n"); process.exit(2); }
221
+ const edits = {};
222
+ for (const kv of opts.set || []) { const [k, v] = kv.split("="); edits[k] = v; }
223
+ const r = mutate(db, { candidate_id, record, dimension, value, action: sub, edits, now: nowIso(), actor: ACTOR("review") });
224
+ out(`${sub}: ${r.changed} changed, ${r.remaining ?? 0} still in review on ${record}`);
225
+ } finally { db.close(); }
226
+ return;
227
+ }
228
+
229
+ if (cmd === "lint") {
230
+ const { lintText } = require("../store/ingest");
231
+ if (!opts.kind || !opts.file) { process.stderr.write("lint --kind <kind> --file <text file> [--application <company>]\n"); process.exit(2); }
232
+ const db = openStore(home);
233
+ try {
234
+ const text = fs.readFileSync(opts.file, "utf8");
235
+ const application_id = findApplication(db, cfg.candidate_id, opts.application);
236
+ const r = lintText(db, { candidate_id: cfg.candidate_id, kind: opts.kind, text, application_id });
237
+ out(`${path.basename(opts.file)} as ${opts.kind}: ${r.blocks.length} blocks, ${r.warnings.length} warnings`);
238
+ printFindings(out, r);
239
+ process.exitCode = r.blocks.length ? 1 : 0;
240
+ } finally { db.close(); }
241
+ return;
242
+ }
243
+
244
+ if (cmd === "ingest") {
245
+ const { saveReply: ingestDocument } = require("../store/reply");
246
+ if (!opts.file) { process.stderr.write("ingest --file <json or text> (--run <id> | --op <op> --interview-id <id> | --posting-id <id>)\n"); process.exit(2); }
247
+ const db = openStore(home);
248
+ try {
249
+ const content = fs.readFileSync(opts.file, "utf8");
250
+ const r = ingestDocument(db, { run_id: opts.run, op: opts.op, interview_id: opts["interview-id"], posting_id: opts["posting-id"],
251
+ content, route: opts.route || "paste", actor: ACTOR("ingest"), now: nowIso() });
252
+ out(`${r.status.toUpperCase()} run ${r.run_id} attempt ${r.attempt}${r.document_id ? " document " + r.document_id : ""}${r.question_ids ? ` ${r.question_ids.length} questions` : ""}${r.pending_ids ? ` ${r.pending_ids.length} pending rows` : ""}${r.stop ? " STOP: hand this to the user" : ""}`);
253
+ for (const e of r.errors || []) out(` ${e}`);
254
+ printFindings(out, r);
255
+ if (r.status === "passed") out(`render it: gafj render --document ${r.document_id}`);
256
+ else if (!r.stop) out(`fix the reply and run the same command again (attempt ${r.attempt + 1} of 3)`);
257
+ process.exitCode = r.status === "passed" ? 0 : 1;
258
+ } finally { db.close(); }
259
+ return;
260
+ }
261
+
262
+ if (cmd === "export") {
263
+ const { exportTracker } = require("../store/export_xlsx");
264
+ const dir = opts.out || cfg.export_dir || path.join(home.dir, "exports");
265
+ fs.mkdirSync(dir, { recursive: true });
266
+ const db = openStore(home);
267
+ try {
268
+ const r = await exportTracker(db, { candidate_id: cfg.candidate_id, outDir: dir, now: nowIso() });
269
+ out(`wrote ${r.file} (${r.rows} rows)`);
270
+ } finally { db.close(); }
271
+ return;
272
+ }
273
+
274
+ if (cmd === "backup") {
275
+ const { backupDir, backupTo } = require("../store/backup_dir");
276
+ const dir = opts.out || backupDir(cfg, cfg.export_dir || home.dir);
277
+ const db = openStore(home);
278
+ try {
279
+ const r = await backupTo(db, dir, nowIso());
280
+ out(`backup written: ${r.file} (${r.method}${r.pruned ? `, ${r.pruned} old copies removed` : ""})`);
281
+ } finally { db.close(); }
282
+ return;
283
+ }
284
+
285
+ if (cmd === "restore") {
286
+ const { restore } = require("../store/backup");
287
+ const file = opts._[0];
288
+ if (!file || !opts.into) { process.stderr.write("restore <backup file> --into <home dir> [--into-live --yes]\n"); process.exit(2); }
289
+ if (opts["into-live"] && !opts.yes) { process.stderr.write("--into-live replaces a live store; add --yes to confirm\n"); process.exit(2); }
290
+ const r = await restore(file, opts.into, { intoLive: !!opts["into-live"], now: nowIso() });
291
+ out(`restored to ${r.file}, schema version ${r.version}`);
292
+ return;
293
+ }
294
+
295
+ if (cmd === "list") {
296
+ const db = openStore(home);
297
+ try {
298
+ const c = cfg.candidate_id;
299
+ const what = opts._[0] || "postings";
300
+ const q = {
301
+ postings: ["SELECT id, status, company, title, (SELECT count(*) FROM scoring s WHERE s.posting_id = p.id AND s.status = 'frozen') AS frozen FROM posting p WHERE candidate_id = ? ORDER BY captured_at DESC",
302
+ (r) => `${r.id} ${r.status.padEnd(8)} ${r.frozen ? "frozen " : "unscored"} ${r.company}: ${r.title}`],
303
+ applications: ["SELECT a.id, a.status, p.company, p.title FROM application a JOIN posting p ON p.id = a.posting_id WHERE p.candidate_id = ? ORDER BY a.applied_at DESC",
304
+ (r) => `${r.id} ${r.status.padEnd(9)} ${r.company}: ${r.title}`],
305
+ interviews: ["SELECT i.id, i.round_label, i.format, i.status, i.scheduled_at, i.tz, p.company FROM interview i JOIN application a ON a.id = i.application_id JOIN posting p ON p.id = a.posting_id WHERE p.candidate_id = ? ORDER BY i.scheduled_at DESC",
306
+ (r) => `${r.id} ${r.scheduled_at} ${r.tz} ${r.company} ${r.round_label} ${r.format} ${r.status}`],
307
+ documents: ["SELECT d.id, d.kind, d.status, d.created_at, d.rendered_rel_path, coalesce(p.company, p2.company, p3.company) AS company FROM document d LEFT JOIN posting p ON p.id = d.posting_id LEFT JOIN interview i ON i.id = d.interview_id LEFT JOIN application a ON a.id = coalesce(d.application_id, i.application_id) LEFT JOIN posting p2 ON p2.id = a.posting_id LEFT JOIN posting p3 ON p3.id = p2.id WHERE d.candidate_id = ? ORDER BY d.created_at DESC",
308
+ (r) => `${r.id} ${r.kind.padEnd(12)} ${r.status.padEnd(7)} ${r.created_at} ${r.company || ""} ${r.rendered_rel_path || ""}`],
309
+ runs: ["SELECT r.id, r.operation, r.route, r.final_result, r.started_at, (SELECT count(*) FROM ai_attempt t WHERE t.ai_run_id = r.id) AS attempts FROM ai_run r WHERE r.candidate_id = ? ORDER BY r.started_at DESC LIMIT 50",
310
+ (r) => `${r.id} ${r.operation.padEnd(12)} ${r.route.padEnd(7)} ${r.final_result.padEnd(8)} attempts ${r.attempts} ${r.started_at}`],
311
+ }[what];
312
+ if (!q) { process.stderr.write("list postings|applications|interviews|documents|runs\n"); process.exit(2); }
313
+ for (const r of db.prepare(q[0]).all(c)) out(q[1](r));
314
+ } finally { db.close(); }
315
+ return;
316
+ }
317
+
318
+ if (cmd === "serve-hosted") {
319
+ // The hosted server (plan W): many users, one store each, sign-in instead of a launch cookie. Configured by environment only.
320
+ const { createHostedServer } = require("../http/hosted");
321
+ const { firebaseVerifier } = require("../http/identity");
322
+ const { Log } = require("../store/log");
323
+ const env = process.env;
324
+ const root = env.GAFJ_HOSTED_ROOT || "/data";
325
+ if (!env.GAFJ_FIREBASE_PROJECT_ID) { process.stderr.write("serve-hosted needs GAFJ_FIREBASE_PROJECT_ID (and GAFJ_FIREBASE_API_KEY, GAFJ_FIREBASE_AUTH_DOMAIN for the sign-in page)\n"); process.exit(2); }
326
+ const log = new Log({ dir: path.join(root, "logs"), stderr: true });
327
+ log.rotate();
328
+ const srv = createHostedServer({
329
+ root, host: env.GAFJ_HOST || null, port: Number(env.PORT || 8080), version: pkg.version, log,
330
+ verifyIdToken: firebaseVerifier({ projectId: env.GAFJ_FIREBASE_PROJECT_ID }),
331
+ firebase: { apiKey: env.GAFJ_FIREBASE_API_KEY || null, authDomain: env.GAFJ_FIREBASE_AUTH_DOMAIN || `${env.GAFJ_FIREBASE_PROJECT_ID}.firebaseapp.com`, projectId: env.GAFJ_FIREBASE_PROJECT_ID },
332
+ relay: env.GAFJ_RELAY_URL ? { url: env.GAFJ_RELAY_URL, service_key: env.GAFJ_RELAY_SERVICE_KEY || "" } : null,
333
+ insecure: env.GAFJ_HOSTED_INSECURE === "1",
334
+ });
335
+ const { origin } = await srv.listen(env.GAFJ_BIND || "0.0.0.0");
336
+ log.info("serve_start", { entity: "hosted", entity_id: String(env.PORT || 8080), actor: "cli" });
337
+ out(`gafj ${pkg.version} hosted at ${origin} (root ${root}, host ${env.GAFJ_HOST || "any"})`);
338
+ const stop = async () => { log.info("serve_stop", { entity: "hosted", entity_id: String(env.PORT || 8080), actor: "cli" }); await srv.close(); process.exit(0); };
339
+ process.on("SIGINT", stop);
340
+ process.on("SIGTERM", stop);
341
+ await new Promise(() => {});
342
+ }
343
+
344
+ if (cmd === "packet") {
345
+ const { openRun } = require("../store/packets");
346
+ const { renderPasteText } = require("../bridge/paste");
347
+ const op = opts._[0];
348
+ if (!op || (op !== "discover" && !opts["interview-id"] && !opts["posting-id"])) { process.stderr.write("packet <op> (--interview-id <id> | --posting-id <id>) [--out <dir>], or packet discover\n"); process.exit(2); }
349
+ const db = openStore(home);
350
+ try {
351
+ if (op === "discover") require("../store/discover").assertCanOpen(db, cfg.candidate_id);
352
+ const r = openRun(db, { op, candidate_id: cfg.candidate_id, interview_id: opts["interview-id"], posting_id: opts["posting-id"], route: "paste", actor: ACTOR("packet"), now: nowIso() });
353
+ const dir = opts.out || path.join(cfg.export_dir || home.dir, "packets");
354
+ fs.mkdirSync(dir, { recursive: true });
355
+ const file = path.join(dir, `packet-${op}-${r.run_id}.md`);
356
+ fs.writeFileSync(file, renderPasteText(r.packet, { run_id: r.run_id }), "utf8");
357
+ out(`run ${r.run_id}`);
358
+ out(`packet ${file} (${r.packet.kb_ids.length} records, ${Buffer.byteLength(fs.readFileSync(file))} bytes)`);
359
+ out(`paste it into any chat, save the JSON reply, then: gafj ingest --run ${r.run_id} --file <reply>`);
360
+ } finally { db.close(); }
361
+ return;
362
+ }
363
+
364
+ if (cmd === "score-freeze") {
365
+ const { proposeFromScan, freezeScoring } = require("../store/scoring");
366
+ if (!opts["posting-id"]) { process.stderr.write("score-freeze --posting-id <id> [--from-scan] [--categories <json>] [--scores <json>] [--propose-only]\n"); process.exit(2); }
367
+ const db = openStore(home);
368
+ try {
369
+ const now = nowIso();
370
+ let categories = opts.categories ? JSON.parse(fs.readFileSync(opts.categories, "utf8")) : null;
371
+ if (categories && categories.categories) categories = categories.categories;
372
+ if (opts["from-scan"]) {
373
+ const p = proposeFromScan(db, { posting_id: opts["posting-id"], now, actor: ACTOR("score-freeze") });
374
+ out(`proposed ${p.categories.length} categories from the scan (scoring ${p.scoring_id})`);
375
+ if (opts["propose-only"]) {
376
+ const dir = opts.out || path.join(cfg.export_dir || home.dir, "packets");
377
+ fs.mkdirSync(dir, { recursive: true });
378
+ const file = path.join(dir, `categories-${opts["posting-id"]}.json`);
379
+ fs.writeFileSync(file, JSON.stringify({ categories: p.categories }, null, 2) + "\n");
380
+ out(`edit ${file} then: gafj score-freeze --posting-id ${opts["posting-id"]} --categories ${file}`);
381
+ return;
382
+ }
383
+ }
384
+ const scores = opts.scores ? JSON.parse(fs.readFileSync(opts.scores, "utf8")) : null;
385
+ const r = freezeScoring(db, { posting_id: opts["posting-id"], categories, scores: scores && scores.scores || scores, now, actor: ACTOR("score-freeze"), thresholds: cfg.thresholds });
386
+ out(`frozen ${r.scoring_id}: fit ${r.fit_score} ${r.verdict} (${r.verdict_reason})`);
387
+ for (const c of r.categories) out(` w${String(c.weight).padStart(2)} s${c.score} ${c.category.slice(0, 60).padEnd(60)} ${c.evidence_strength.padEnd(8)} ${c.evidence_ids.slice(0, 3).join(", ")}`);
388
+ } finally { db.close(); }
389
+ return;
390
+ }
391
+
392
+ if (cmd === "render") {
393
+ const { renderDocument } = require("../store/render");
394
+ if (!opts.document) { process.stderr.write("render --document <id> [--out <dir>]\n"); process.exit(2); }
395
+ const db = openStore(home);
396
+ try {
397
+ const dir = opts.out || path.join(cfg.export_dir || home.dir, "documents");
398
+ const kind = db.prepare("SELECT kind FROM document WHERE id = ?").get(opts.document);
399
+ if (kind && kind.kind === "practice") {
400
+ const { writeWorkbook } = require("../store/practice");
401
+ const r = await writeWorkbook(db, { document_id: opts.document, outDir: dir, homeDir: home.dir, now: nowIso(), actor: ACTOR("render") });
402
+ out(`wrote ${r.file} (${r.planted} planted anomalies; the Solution tab is the grader)`);
403
+ return;
404
+ }
405
+ const r = renderDocument(db, { document_id: opts.document, outDir: dir, homeDir: home.dir, now: nowIso(), actor: ACTOR("render") });
406
+ out(`wrote ${r.file} (${r.bytes} bytes)`);
407
+ } finally { db.close(); }
408
+ return;
409
+ }
410
+
411
+ if (cmd === "init") {
412
+ const onboarding = require("../store/onboarding");
413
+ const db = openStore(home);
414
+ try {
415
+ if (cfg.candidate_id && !opts.force) { out(`a candidate already exists (${cfg.candidate_id}); onboarding continues with: gafj onboard add <files>`); return; }
416
+ const id = ulid(Date.now());
417
+ db.prepare("INSERT INTO candidate (id, name, created_at) VALUES (?, ?, ?)").run(id, opts.name || "Candidate", nowIso());
418
+ cfg.candidate_id = id;
419
+ writeConfig(home.dir, cfg);
420
+ const b = onboarding.openBatch(db, { candidate_id: id, now: nowIso(), actor: ACTOR("init") });
421
+ out(`candidate ${id} (${opts.name || "Candidate"}) with onboarding batch ${b.batch_id}`);
422
+ out(`next: gafj onboard add <your resumes: docx, pdf, md, txt>`);
423
+ } finally { db.close(); }
424
+ return;
425
+ }
426
+
427
+ if (cmd === "onboard") {
428
+ const onboarding = require("../store/onboarding");
429
+ const db = openStore(home);
430
+ try {
431
+ const candidate_id = cfg.candidate_id;
432
+ if (!candidate_id) { process.stderr.write("no candidate yet; run gafj init --name <name>\n"); process.exit(2); }
433
+ const sub = opts._[0] || "status";
434
+ const now = nowIso();
435
+ if (sub === "add") {
436
+ const b = onboarding.openBatch(db, { candidate_id, now, actor: ACTOR("onboard") });
437
+ for (const f of opts._.slice(1)) {
438
+ const r = await onboarding.addSource(db, { candidate_id, batch_id: b.batch_id, filename: path.basename(f), buffer: fs.readFileSync(f), now: nowIso(), actor: ACTOR("onboard") });
439
+ out(`${path.basename(f)}: ${r.created ? (r.empty ? "FAILED: " + r.failure : `${r.chars} characters`) : "already uploaded"} ${r.source_document_id}`);
440
+ }
441
+ out(`next: gafj onboard packet --source <id>`);
442
+ return;
443
+ }
444
+ if (sub === "status") {
445
+ const v = onboarding.batchView(db, candidate_id);
446
+ if (!v) { out("no batch; run gafj init"); return; }
447
+ out(`batch ${v.batch_id} ${v.status}: ${v.sources.length} sources, ${v.pending.length} pending, ${v.groups.length} duplicate groups`);
448
+ for (const s of v.sources) out(` ${s.source_document_id} ${s.filename.padEnd(40)} ${s.empty ? "FAILED" : s.chars + " chars"} runs ${s.runs} pending ${s.pending}`);
449
+ for (const p of v.pending) out(` pending ${p.pending_id} ${p.dedup_group_id ? "[group " + p.dedup_group_id.slice(-6) + "]" : ""} ${p.draft.company}: ${p.draft.title}`);
450
+ return;
451
+ }
452
+ if (sub === "packet") {
453
+ const { openRun } = require("../store/packets");
454
+ const { renderPasteText } = require("../bridge/paste");
455
+ if (!opts.source) { process.stderr.write("onboard packet --source <source document id>\n"); process.exit(2); }
456
+ const r = openRun(db, { op: "extract", source_document_id: opts.source, route: "paste", actor: ACTOR("onboard"), now });
457
+ const dir = opts.out || path.join(cfg.export_dir || home.dir, "packets");
458
+ fs.mkdirSync(dir, { recursive: true });
459
+ const file = path.join(dir, `packet-extract-${r.run_id}.md`);
460
+ fs.writeFileSync(file, renderPasteText(r.packet, { run_id: r.run_id }), "utf8");
461
+ out(`run ${r.run_id}\npacket ${file}\npaste it into any chat, save the JSON reply, then: gafj onboard ingest --run ${r.run_id} --file <reply>`);
462
+ return;
463
+ }
464
+ if (sub === "ingest") {
465
+ if (!opts.run || !opts.file) { process.stderr.write("onboard ingest --run <id> --file <json>\n"); process.exit(2); }
466
+ const r = onboarding.saveExtraction(db, { run_id: opts.run, content: fs.readFileSync(opts.file, "utf8"), route: "paste", actor: ACTOR("onboard"), now });
467
+ out(`${r.status.toUpperCase()} attempt ${r.attempt}: ${r.pending_ids.length} pending rows${r.stop ? " STOP: open a fresh packet" : ""}`);
468
+ for (const e of r.errors) out(` ${e}`);
469
+ if (r.status === "passed") out(`next: gafj onboard dedup, then confirm on the knowledge base screen or with gafj review`);
470
+ process.exitCode = r.status === "passed" ? 0 : 1;
471
+ return;
472
+ }
473
+ if (sub === "dedup") {
474
+ const v = onboarding.batchView(db, candidate_id);
475
+ const groups = onboarding.suggestDedup(db, { candidate_id, batch_id: v.batch_id, now, actor: ACTOR("onboard") });
476
+ out(`${groups.length} duplicate groups suggested; nothing merges without you`);
477
+ for (const g of groups) out(` ${g.dedup_group_id} ${g.strength}: ${g.pending_ids.join(", ")} merge with: gafj onboard merge ${g.dedup_group_id}`);
478
+ return;
479
+ }
480
+ if (sub === "merge") {
481
+ const r = onboarding.mergeGroup(db, { candidate_id, dedup_group_id: opts._[1], keep_id: opts.keep, now, actor: ACTOR("onboard") });
482
+ out(`merged ${r.merged} rows into ${r.pending_id}`);
483
+ return;
484
+ }
485
+ if (sub === "style") { out(JSON.stringify(onboarding.proposeStyle(db, candidate_id), null, 2)); return; }
486
+ process.stderr.write("onboard add|status|packet|ingest|dedup|merge|style\n"); process.exit(2);
487
+ } finally { db.close(); }
488
+ }
489
+
490
+ if (cmd === "serve") {
491
+ const { createServer, ensureSessionToken, readLock, writeLock, removeLock, pidAlive } = require("../http/server");
492
+ const pkg = require("../package.json");
493
+ if (!cfg.candidate_id) { process.stderr.write("no candidate yet; run gafj import first\n"); process.exit(2); }
494
+ const lock = readLock(home.dir);
495
+ if (lock && pidAlive(lock.pid) && !opts.force) {
496
+ out(`gafj serve is already running (pid ${lock.pid}) at http://127.0.0.1:${lock.port}; run "gafj url" for a launch link, or serve --force`);
497
+ return;
498
+ }
499
+ ensureSessionToken(cfg);
500
+ writeConfig(home.dir, cfg);
501
+ const { Log } = require("../store/log");
502
+ const log = new Log({ dir: path.join(home.dir, "logs") });
503
+ log.rotate();
504
+ const db = openStore(home);
505
+ const abandoned = db.prepare("UPDATE ai_run SET final_result = 'abandoned', finished_at = ? WHERE final_result = 'pending' AND started_at < ?")
506
+ .run(nowIso(), new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString()).changes;
507
+ if (abandoned) out(`${abandoned} stale pending runs marked abandoned`);
508
+ const exportDir = cfg.export_dir || path.join(home.dir, "exports");
509
+ {
510
+ const { backupDir, dailyBackup } = require("../store/backup_dir");
511
+ const b = await dailyBackup(db, backupDir(cfg, exportDir), nowIso());
512
+ if (b.error) out(`daily backup skipped: ${b.error}`);
513
+ else if (!b.skipped) { out(`daily backup written: ${b.file}`); require("../store/settings").recordBackup(home.dir, { file: b.file, at: nowIso(), method: "daily", by: "serve" }); }
514
+ }
515
+ const srv = createServer({ db, cfg, home: home.dir, exportDir, port: Number(opts.port || cfg.port || 0) || 0, version: pkg.version, log });
516
+ const { port, origin } = await srv.listen();
517
+ writeLock(home.dir, { pid: process.pid, port, started_at: nowIso() });
518
+ log.info("serve_start", { entity: "server", entity_id: String(port), actor: "cli" });
519
+ if (!cfg.port) { cfg.port = port; writeConfig(home.dir, cfg); }
520
+ const url = srv.launchUrl();
521
+ out(`gafj ${pkg.version} serving ${origin} (store ${path.join(home.dir, "gafj.db")})`);
522
+ out(`open: ${url}`);
523
+ if (!opts["no-open"]) openBrowser(url);
524
+ const stop = async () => { out("stopping"); log.info("serve_stop", { entity: "server", entity_id: String(port), actor: "cli" }); await srv.close(); try { db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } catch (_) { /* busy */ } db.close(); removeLock(home.dir); process.exit(0); };
525
+ process.on("SIGINT", stop);
526
+ process.on("SIGTERM", stop);
527
+ await new Promise(() => {});
528
+ }
529
+
530
+ if (cmd === "url") {
531
+ const { readLock, pidAlive } = require("../http/server");
532
+ const lock = readLock(home.dir);
533
+ if (!lock || !pidAlive(lock.pid)) { process.stderr.write("gafj serve is not running; start it with gafj serve\n"); process.exit(1); }
534
+ const r = await fetch(`http://127.0.0.1:${lock.port}/launch/new${opts.host ? "?host=" + encodeURIComponent(opts.host) : ""}`, { headers: { "x-gafj-token": cfg.session_token || "", host: `127.0.0.1:${lock.port}` } });
535
+ if (!r.ok) { process.stderr.write(`the running server refused (${r.status}); tokens may differ, restart serve\n`); process.exit(1); }
536
+ const { url } = await r.json();
537
+ out(url);
538
+ if (opts.qr) out(qrText(url));
539
+ if (!opts["no-open"] && !opts.host) openBrowser(url);
540
+ return;
541
+ }
542
+
543
+ if (cmd === "allow-host") {
544
+ const name = String(opts._[0] || opts.remove || "").toLowerCase().trim();
545
+ if (!name) { process.stderr.write("allow-host <hostname> | allow-host --remove <hostname>\n"); process.exit(2); }
546
+ const list = new Set(cfg.allowed_hosts || []);
547
+ if (opts.remove) list.delete(name); else list.add(name);
548
+ cfg.allowed_hosts = [...list];
549
+ writeConfig(home.dir, cfg);
550
+ out(`allowed hosts: ${cfg.allowed_hosts.join(", ") || "(none)"}; restart gafj serve`);
551
+ return;
552
+ }
553
+
554
+ if (cmd === "rotate-token") {
555
+ const crypto = require("crypto");
556
+ cfg.session_token = crypto.randomBytes(32).toString("hex");
557
+ writeConfig(home.dir, cfg);
558
+ out("session token rotated; restart gafj serve and open a fresh launch URL");
559
+ return;
560
+ }
561
+
562
+ if (cmd === "install-startup") {
563
+ const { execFileSync } = require("child_process");
564
+ const cli = path.resolve(__filename);
565
+ const nodeExe = process.execPath;
566
+ const os = require("os");
567
+ const plat = process.platform;
568
+ let plan;
569
+ if (plat === "win32") {
570
+ const tr = `"${nodeExe}" "${cli}" serve --no-open`;
571
+ plan = { how: "Windows scheduled task GAFJ at logon", commands: opts.remove
572
+ ? [["schtasks", ["/Delete", "/TN", "GAFJ", "/F"]]]
573
+ : [["schtasks", ["/Create", "/SC", "ONLOGON", "/TN", "GAFJ", "/TR", tr, "/RL", "LIMITED", "/F"]]] };
574
+ } else if (plat === "darwin") {
575
+ const plist = path.join(os.homedir(), "Library", "LaunchAgents", "com.gafj.serve.plist");
576
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>com.gafj.serve</string>\n<key>ProgramArguments</key><array><string>${nodeExe}</string><string>${cli}</string><string>serve</string><string>--no-open</string></array>\n<key>EnvironmentVariables</key><dict><key>GAFJ_HOME</key><string>${home.dir}</string></dict>\n<key>RunAtLoad</key><true/><key>KeepAlive</key><false/>\n</dict></plist>\n`;
577
+ plan = { how: `launchd agent ${plist}`, file: opts.remove ? null : { path: plist, text: xml }, commands: opts.remove
578
+ ? [["launchctl", ["unload", plist]]] : [["launchctl", ["load", plist]]] };
579
+ } else {
580
+ const unit = path.join(os.homedir(), ".config", "systemd", "user", "gafj.service");
581
+ const text = `[Unit]\nDescription=GAF-J local server\n\n[Service]\nEnvironment=GAFJ_HOME=${home.dir}\nExecStart=${nodeExe} ${cli} serve --no-open\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n`;
582
+ plan = { how: `systemd user unit ${unit}`, file: opts.remove ? null : { path: unit, text }, commands: opts.remove
583
+ ? [["systemctl", ["--user", "disable", "--now", "gafj.service"]]] : [["systemctl", ["--user", "daemon-reload"]], ["systemctl", ["--user", "enable", "--now", "gafj.service"]]] };
584
+ }
585
+ out(plan.how);
586
+ if (plan.file) out(`write ${plan.file.path}:\n${plan.file.text}`);
587
+ for (const [c, a] of plan.commands) out(`run: ${c} ${a.map((x) => (/\s/.test(x) ? JSON.stringify(x) : x)).join(" ")}`);
588
+ if (opts.print) return;
589
+ if (plan.file) { fs.mkdirSync(path.dirname(plan.file.path), { recursive: true }); fs.writeFileSync(plan.file.path, plan.file.text); }
590
+ if (plan.file && opts.remove === true) { /* nothing to write */ }
591
+ for (const [c, a] of plan.commands) {
592
+ try { execFileSync(c, a, { stdio: "inherit" }); } catch (e) { process.stderr.write(`${c} failed: ${e.message}\nrun the command above by hand\n`); process.exitCode = 1; }
593
+ }
594
+ if (opts.remove && plat !== "win32" && plan.how) { try { fs.unlinkSync(plat === "darwin" ? path.join(os.homedir(), "Library", "LaunchAgents", "com.gafj.serve.plist") : path.join(os.homedir(), ".config", "systemd", "user", "gafj.service")); } catch (_) { /* gone */ } }
595
+ return;
596
+ }
597
+
598
+ if (cmd === "mcp") {
599
+ const { serve } = require("../mcp/server");
600
+ if (!cfg.candidate_id) { process.stderr.write("no candidate yet; run gafj import first\n"); process.exit(2); }
601
+ const db = openStore(home);
602
+ const exportDir = cfg.export_dir || path.join(home.dir, "exports");
603
+ const handle = await serve({ db, candidate_id: cfg.candidate_id, thresholds: cfg.thresholds, exportDir });
604
+ const stop = async () => { try { await handle.close(); } catch (_) { /* closing */ } db.close(); process.exit(0); };
605
+ process.on("SIGINT", stop);
606
+ process.on("SIGTERM", stop);
607
+ process.stdin.on("end", stop);
608
+ return;
609
+ }
610
+
611
+ if (cmd === "print-mcp-config") {
612
+ const cli = path.resolve(__filename);
613
+ const snippet = { mcpServers: { gafj: { command: process.execPath, args: [cli, "mcp"], env: { GAFJ_HOME: home.dir } } } };
614
+ out("Claude Desktop (claude_desktop_config.json), merge into mcpServers:");
615
+ out(JSON.stringify(snippet, null, 2));
616
+ out("");
617
+ out("Claude Code:");
618
+ out(` claude mcp add gafj -e GAFJ_HOME=${JSON.stringify(home.dir)} -- ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} mcp`);
619
+ out("");
620
+ out(`Store: ${path.join(home.dir, "gafj.db")}. Never use npx here; the paths above are absolute on purpose.`);
621
+ return;
622
+ }
623
+
624
+ process.stderr.write(`${cmd}: not built yet (see the plan for its phase)\n`);
625
+ process.exit(3);
626
+ }
627
+
628
+ main(process.argv.slice(2)).catch((e) => { process.stderr.write(`${e.message}\n`); process.exit(1); });