@gafj/gafj 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ Status: phases 1 through 6 built and tested (82 public tests on the synthetic ca
28
28
  - Bullet register (screen 6): `house_rules.bullet_register` is `result_first` (default, RAS), `action_first`, or `posting`; it travels in the resume packet's HOUSE RULES and `core/rules/resume.md` says what each means. Same facts, same claims, same gate; only word order.
29
29
  - Backup folder (screen 6): `config.backup_dir`, meant for a synced folder (Drive, OneDrive, Dropbox); `store/backup_dir.js` writes dated consistent copies there (`gafj backup`, the Back up now button, and one a day at `serve` start), keeps the newest 14, never prunes a pre-restore copy, and refuses the data folder itself. The live store never goes to a synced folder.
30
30
  - Hosted tier (plan W, gates 2 and 4 as code): `http/hosted.js` runs the same routes, screens, and doors behind a Google sign-in with one store per user under `<root>/tenants/<uid>/`; the session names the uid, the uid names the folder, and no query spans tenants (`test/hosted.test.js` drives every parameterized route as user B with user A's ids and expects 404 and no leaked text). Provider keys, MCP config, and the restore test are 404 for hosted users; the model is reached through the relay with a service key and the uid (`x-gafj-service`, `x-gafj-uid`; the relay's second auth path), never a stored user token. `/account/export` streams a restorable backup, `/account/delete` removes the folder and every session. `http/identity.js` verifies Firebase ID tokens with node:crypto against Google's JWK set, no SDK. `ui/hosted/signin.html` is the one page with a loosened CSP (Firebase Auth from gstatic). `gafj serve-hosted` is configured by environment only; `deploy/hosted/` holds the Dockerfile and fly.toml; `functions/relay/deploy.js` builds the relay with Firebase Admin, Stripe, and the Anthropic SDK and `functions/index.js` exports it as `relay`. Nothing is deployed; `docs/operator-setup.md` is the list of what only the operator can do.
31
- - `bin/cli.js`: `migrate`, `import`, `snapshot`, `review`, `lint`, `list`, `score-freeze`, `packet`, `ingest`, `render`, `export`, `backup`, `restore`, `mcp`, `print-mcp-config`, `serve`, `url`, `rotate-token`, `install-startup`, `allow-host`, `init`, `onboard`; every other command names its phase and exits
31
+ - `bin/cli.js`: `start` (the one command: candidate if none, then url or serve), `migrate`, `import`, `snapshot`, `review`, `lint`, `list`, `score-freeze`, `packet`, `ingest`, `render`, `export`, `backup`, `restore`, `mcp`, `print-mcp-config`, `serve`, `url`, `rotate-token`, `install-startup`, `allow-host`, `init`, `onboard`; every other command names its phase and exits
32
32
 
33
33
  Onboarding another candidate, phase 5b:
34
34
 
package/bin/cli.js CHANGED
@@ -39,6 +39,7 @@ const COMMANDS = {
39
39
  render: "write a passed document as Markdown: render --document <id> [--out <dir>]",
40
40
  list: "list ids: list postings|applications|interviews|documents|runs",
41
41
  mcp: "run the stdio MCP server for Claude Desktop or Claude Code (stdout is the wire)",
42
+ start: "the one command: creates your candidate the first time (asks your name), then opens the app: start [--name <name>]",
42
43
  serve: "run the local UI server on 127.0.0.1: serve [--port <n>] [--force] [--no-open]",
43
44
  url: "print a fresh launch URL for the running server (and open the browser): url [--no-open] [--host <allowed host>] [--qr]",
44
45
  "allow-host": "let a local HTTPS proxy front the server for your phone: allow-host <name.tail1234.ts.net> | allow-host --remove <name>",
@@ -487,10 +488,35 @@ async function main(argv) {
487
488
  } finally { db.close(); }
488
489
  }
489
490
 
491
+ if (cmd === "start") {
492
+ // One command for a new user and for every day after: make the candidate if there is none (asking the
493
+ // name once), then open the app: a fresh launch link if the server is up, otherwise serve.
494
+ const { readLock, pidAlive } = require("../http/server");
495
+ if (!cfg.candidate_id) {
496
+ let name = opts.name ? String(opts.name) : "";
497
+ if (!name && process.stdin.isTTY) {
498
+ const rl = require("readline").createInterface({ input: process.stdin, output: process.stdout });
499
+ name = await new Promise((resolve) => rl.question("Your name, as it should appear on a resume: ", (a) => { rl.close(); resolve(String(a || "").trim()); }));
500
+ }
501
+ name = name || "Candidate";
502
+ const db = openStore(home);
503
+ try {
504
+ const id = ulid(Date.now());
505
+ db.prepare("INSERT INTO candidate (id, name, created_at) VALUES (?, ?, ?)").run(id, name, nowIso());
506
+ cfg.candidate_id = id;
507
+ writeConfig(home.dir, cfg);
508
+ require("../store/onboarding").openBatch(db, { candidate_id: id, now: nowIso(), actor: ACTOR("start") });
509
+ } finally { db.close(); }
510
+ out(`welcome, ${name}. Your data lives in ${home.dir}`);
511
+ }
512
+ const lock = readLock(home.dir);
513
+ return main(lock && pidAlive(lock.pid) ? ["url"] : ["serve"]);
514
+ }
515
+
490
516
  if (cmd === "serve") {
491
517
  const { createServer, ensureSessionToken, readLock, writeLock, removeLock, pidAlive } = require("../http/server");
492
518
  const pkg = require("../package.json");
493
- if (!cfg.candidate_id) { process.stderr.write("no candidate yet; run gafj import first\n"); process.exit(2); }
519
+ if (!cfg.candidate_id) { process.stderr.write("no candidate yet; run gafj start\n"); process.exit(2); }
494
520
  const lock = readLock(home.dir);
495
521
  if (lock && pidAlive(lock.pid) && !opts.force) {
496
522
  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`);
package/http/api.js CHANGED
@@ -151,6 +151,12 @@ route("GET", "/api/kb/questions", (c, p, b, q) => discover.listQuestions(c.db, c
151
151
  route("POST", "/api/kb/questions/:id/answer", (c, p, b) => discover.answerQuestion(c.db, { candidate_id: c.candidate_id, question_id: p.id, draft: need(b, "draft"), now: c.now(), actor: ACTOR("answer_question") }));
152
152
  route("POST", "/api/kb/questions/:id/dismiss", (c, p) => discover.dismissQuestion(c.db, { candidate_id: c.candidate_id, question_id: p.id, now: c.now(), actor: ACTOR("dismiss_question") }));
153
153
 
154
+ // guided setup (first run)
155
+ const setup = require("../store/setup");
156
+ route("GET", "/api/setup", (c) => setup.setupView(c.db, c.home, c.candidate_id));
157
+ route("POST", "/api/setup/ai", (c, p, b) => { const r = setup.chooseAi(c.db, c.home, c.candidate_id, { route: need(b, "route"), now: c.now(), actor: ACTOR("setup") }); if (c.reload) c.reload(); return r; });
158
+ route("POST", "/api/setup/dismiss", (c, p, b) => setup.dismiss(c.db, c.home, c.candidate_id, { dismissed: b.dismissed !== false }));
159
+
154
160
  // settings (screen 6)
155
161
  const settings = require("../store/settings");
156
162
  route("GET", "/api/settings", (c) => settings.getSettings(c.db, c.home, c.candidate_id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gafj/gafj",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "GAF-J: a local campaign engine for job search. One SQLite file, one ingest door, your own AI subscription.",
5
5
  "homepage": "https://gaf-j.com",
6
6
  "repository": {
package/store/setup.js ADDED
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The guided setup (first run): six steps in the order they must happen,
5
+ * each computed from the store, so the checklist is never out of date and
6
+ * nothing is stored except two flags in config: which AI route the user
7
+ * chose on purpose (the default is paste, which says nothing), and whether
8
+ * they dismissed the card. The card leaves on its own when every step is done.
9
+ */
10
+
11
+ const { readConfig, writeConfig } = require("./config");
12
+
13
+ const ROUTE_LABEL = { paste: "Paste", mcp: "Claude Desktop", byo_key: "My providers", credits: "Credits" };
14
+
15
+ function setupView(db, homeDir, candidate_id) {
16
+ const cfg = readConfig(homeDir);
17
+ const one = (sql, ...a) => db.prepare(sql).get(...a).c;
18
+ const sources = one("SELECT count(*) c FROM source_document WHERE candidate_id = ?", candidate_id);
19
+ const extracted = one("SELECT count(*) c FROM ai_run WHERE candidate_id = ? AND operation = 'extract' AND final_result = 'passed'", candidate_id);
20
+ const pending = one("SELECT count(*) c FROM pending_accomplishment WHERE candidate_id = ?", candidate_id);
21
+ const confirmed = one("SELECT count(*) c FROM accomplishment WHERE candidate_id = ?", candidate_id);
22
+ const questions = one("SELECT count(*) c FROM kb_question WHERE candidate_id = ?", candidate_id);
23
+ const postings = one("SELECT count(*) c FROM posting WHERE candidate_id = ?", candidate_id);
24
+ const aiChosen = !!(cfg.setup && cfg.setup.ai_chosen);
25
+ const steps = [
26
+ { key: "ai", title: "Tell GAF-J which AI you use", done: aiChosen, detail: aiChosen ? `Using ${ROUTE_LABEL[cfg.route] || cfg.route}` : null },
27
+ { key: "upload", title: "Upload your resumes", done: sources > 0, detail: sources ? `${sources} file${sources === 1 ? "" : "s"} uploaded` : null },
28
+ { key: "extract", title: "Extract records from them", done: extracted > 0 || pending > 0 || confirmed > 0, detail: extracted ? `${extracted} extraction${extracted === 1 ? "" : "s"} saved` : null },
29
+ { key: "confirm", title: "Confirm the records you stand behind", done: confirmed > 0, detail: confirmed ? `${confirmed} confirmed${pending ? `, ${pending} still pending` : ""}` : (pending ? `${pending} waiting for you` : null) },
30
+ { key: "discover", title: "Answer what's missing", done: questions > 0, detail: questions ? `${questions} question${questions === 1 ? "" : "s"} asked` : null },
31
+ { key: "posting", title: "Paste your first job posting", done: postings > 0, detail: postings ? `${postings} posting${postings === 1 ? "" : "s"}` : null },
32
+ ];
33
+ const next = steps.find((s) => !s.done);
34
+ return { steps, next: next ? next.key : null, complete: !next, dismissed: !!(cfg.setup && cfg.setup.dismissed), route: cfg.route || "paste" };
35
+ }
36
+
37
+ /** The user picked how they will use AI; the route is set and the step is done. */
38
+ function chooseAi(db, homeDir, candidate_id, { route, now, actor }) {
39
+ const settings = require("./settings");
40
+ settings.putSettings(db, homeDir, candidate_id, { route }, { now, actor });
41
+ const cfg = readConfig(homeDir);
42
+ cfg.setup = { ...(cfg.setup || {}), ai_chosen: true };
43
+ writeConfig(homeDir, cfg);
44
+ return setupView(db, homeDir, candidate_id);
45
+ }
46
+
47
+ function dismiss(db, homeDir, candidate_id, { dismissed = true } = {}) {
48
+ const cfg = readConfig(homeDir);
49
+ cfg.setup = { ...(cfg.setup || {}), dismissed: !!dismissed };
50
+ writeConfig(homeDir, cfg);
51
+ return setupView(db, homeDir, candidate_id);
52
+ }
53
+
54
+ module.exports = { setupView, chooseAi, dismiss };
package/ui/app.css CHANGED
@@ -135,3 +135,19 @@ input.narrow { width: 76px; }
135
135
  pre.wrap { white-space: pre-wrap; }
136
136
  /* KB "receipt" moment: the confirmed span highlighted inline against its muted source sentence. */
137
137
  mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px; padding: 0 3px; font-weight: 500; }
138
+
139
+ /* guided setup on the dashboard: the next step is open with its words and its button; done steps collapse to a line */
140
+ .setup { margin-top: 10px; border-color: var(--accent); }
141
+ .setup-steps { list-style: none; margin: 10px 0 0; padding: 0; }
142
+ .setup-steps li { display: grid; grid-template-columns: 28px 1fr; gap: 10px; padding: 9px 0; border-top: 1px solid var(--line); align-items: start; }
143
+ .setup-steps li:first-child { border-top: 0; }
144
+ .setup-steps .n { width: 24px; height: 24px; border-radius: 50%; border: 1px solid var(--line-2); display: inline-flex; align-items: center; justify-content: center; font-size: 11px; color: var(--muted); }
145
+ .setup-steps li.done .n { background: var(--good-bg); color: var(--good-bright); border-color: var(--good-bg); }
146
+ .setup-steps li.next .n { border-color: var(--accent); color: var(--accent); font-weight: 600; }
147
+ .setup-steps li.done .t { color: var(--muted); }
148
+ .setup-steps li.later .t { color: var(--dim); }
149
+ .setup-steps li.next .t { font-weight: 600; }
150
+ .setup-steps p { margin: 4px 0 8px; color: var(--muted); max-width: 66ch; }
151
+ .ai-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; margin-top: 4px; }
152
+ .ai-options button { text-align: left; display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; height: 100%; }
153
+ .ai-options button:hover { border-color: var(--accent); }
@@ -1,11 +1,49 @@
1
1
  import { html, useState, useEffect } from "../vendor/preact.mjs";
2
2
  import { api, fmtWhen } from "../lib.js";
3
3
 
4
+ /** The six steps a new user takes, in order, with the words for each and where the button goes. */
5
+ const STEPS = {
6
+ ai: { why: "GAF-J never writes text itself. It hands a packet of your confirmed facts to an AI you already have, then checks every figure in the reply. Pick how you will connect one; you can change it later in Settings.", cta: null },
7
+ upload: { why: "Every resume you have, old ones too. Word, PDF, Markdown, or plain text. The text is read on this PC; nothing is uploaded anywhere.", cta: ["#/kb", "Open Knowledge base"] },
8
+ extract: { why: "Beside each uploaded file, click Extract. A packet opens: copy it into your AI, paste its JSON reply back. Your AI proposes records where every number and phrase is quoted from the file; anything it cannot quote is refused.", cta: ["#/kb", "Extract on Knowledge base"] },
9
+ confirm: { why: "Under Pending, read each proposed record. Confirm the ones you can stand behind in an interview, edit what is off, dismiss the rest. Only confirmed records can ever appear in a document.", cta: ["#/kb", "Review Pending"] },
10
+ discover: { why: "Click Ask what's missing. Your AI reads the confirmed records and asks the questions a coach would: the figure a record lacks, the size of a team, the story behind a bare bullet. Each answer strengthens a record.", cta: ["#/kb", "Ask what's missing"] },
11
+ posting: { why: "Paste the text of a job you want. GAF-J scores it against your record, shows what the posting asks for and what you have, and from there builds the resume, the interview prep, and the rest.", cta: ["#/postings", "Paste a posting"] },
12
+ };
13
+ const AI_OPTIONS = [
14
+ ["paste", "Paste", "Works with any chat you already pay for: ChatGPT, Claude, Gemini. Copy the packet, paste the reply. No keys, no setup."],
15
+ ["mcp", "Claude Desktop", "Claude talks to the app directly through a local connector. Print the config in Settings and add it to Claude Desktop."],
16
+ ["byo_key", "My own API key", "The app calls the provider itself with a key you add in Settings. The key never leaves this PC."],
17
+ ];
18
+
19
+ function Setup({ s, reload }) {
20
+ const [busy, setBusy] = useState(false);
21
+ const choose = async (route) => { setBusy(true); try { await api.post("/api/setup/ai", { route }); await reload(); } finally { setBusy(false); } };
22
+ const done = s.steps.filter((x) => x.done).length;
23
+ return html`<div class="card setup">
24
+ <div class="row between"><b>Getting started: ${done} of ${s.steps.length}</b><button class="small" onClick=${async () => { await api.post("/api/setup/dismiss", {}); reload(); }}>hide</button></div>
25
+ <ol class="setup-steps">${s.steps.map((st, i) => {
26
+ const meta = STEPS[st.key];
27
+ const isNext = s.next === st.key;
28
+ return html`<li key=${st.key} class=${st.done ? "done" : isNext ? "next" : "later"}>
29
+ <span class="n mono">${st.done ? "✓" : i + 1}</span>
30
+ <div>
31
+ <div class="t">${st.title}${st.detail ? html` <span class="muted small">· ${st.detail}</span>` : ""}</div>
32
+ ${isNext ? html`<p class="small">${meta.why}</p>
33
+ ${st.key === "ai" ? html`<div class="ai-options">${AI_OPTIONS.map(([r, label, text]) => html`<button key=${r} disabled=${busy} onClick=${() => choose(r)}><b>${label}</b><span class="small muted">${text}</span></button>`)}</div>`
34
+ : html`<div class="row"><a href=${meta.cta[0]}><button class="primary small">${meta.cta[1]}</button></a></div>`}` : ""}
35
+ </div></li>`;
36
+ })}</ol>
37
+ </div>`;
38
+ }
39
+
4
40
  export function Dashboard({ live }) {
5
41
  const [snap, setSnap] = useState(null);
42
+ const [setup, setSetup] = useState(null);
6
43
  const [msg, setMsg] = useState("");
7
44
  const [err, setErr] = useState("");
8
- useEffect(() => { api.get("/api/snapshot").then(setSnap).catch((e) => setErr(e.message)); }, [live]);
45
+ const loadSetup = () => api.get("/api/setup").then(setSetup).catch(() => setSetup(null));
46
+ useEffect(() => { api.get("/api/snapshot").then(setSnap).catch((e) => setErr(e.message)); loadSetup(); }, [live]);
9
47
  const act = async (path, label) => { setMsg(""); setErr(""); try { const r = await api.post(path); setMsg(`${label}: ${r.file}`); } catch (e) { setErr(e.message); } };
10
48
  if (err && !snap) return html`<p class="error">${err}</p>`;
11
49
  if (!snap) return html`<p class="muted">loading</p>`;
@@ -13,6 +51,8 @@ export function Dashboard({ live }) {
13
51
  return html`<div>
14
52
  <h1>Dashboard</h1>
15
53
  <div class="sub">${new Date(snap.now).toLocaleString()}</div>
54
+ ${setup && !setup.complete && !setup.dismissed ? html`<${Setup} s=${setup} reload=${loadSetup} />` : ""}
55
+ ${setup && !setup.complete && setup.dismissed ? html`<p class="small muted">Setup is hidden. <a href="#/" onClick=${async (e) => { e.preventDefault(); await api.post("/api/setup/dismiss", { dismissed: false }); loadSetup(); }}>Show the steps again</a>.</p>` : ""}
16
56
  <h2>Needs you now</h2>
17
57
  <div class="card">${snap.needs_you.length ? html`<ul class="list">${snap.needs_you.map((n, i) => html`<li key=${i}>
18
58
  <span class=${"need " + (n.kind === "thankyou_due" || n.kind === "review" ? "soft" : "")}>●</span> <a href=${link(n)}>${n.text}</a></li>`)}</ul>` : html`<span class="muted">nothing waiting on you</span>`}</div>
@@ -37,6 +37,25 @@ export function Postings({ live, id }) {
37
37
  const [rows, setRows] = useState([]);
38
38
  const [sel, setSel] = useState(null);
39
39
  const [form, setForm] = useState({ company: "", title: "", source_url: "", location: "", comp_text: "", raw_text: "" });
40
+ const [guessed, setGuessed] = useState({ company: true, title: true });
41
+ // the paste is all a person should have to give; company and title are guessed from its first lines and can be corrected
42
+ const onPaste = (text) => {
43
+ const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 12);
44
+ const next = { ...form, raw_text: text };
45
+ if (guessed.title) {
46
+ const t = lines.find((l) => l.length <= 90 && !/^https?:/i.test(l) && !/^(at|company|location|posted|apply|share)\b/i.test(l));
47
+ next.title = t || "";
48
+ }
49
+ if (guessed.company) {
50
+ let c = "";
51
+ for (const l of lines) { const m = l.match(/^(?:at|company[:\s]+|employer[:\s]+)\s*([^|·,]{2,60})/i); if (m) { c = m[1].trim(); break; } }
52
+ if (!c) { const i = lines.findIndex((l) => l === next.title); const cand = lines[i + 1] || ""; if (cand && cand.length <= 50 && !/^https?:/i.test(cand) && !/\d{4}/.test(cand)) c = cand.replace(/^[·|\-\s]+/, ""); }
53
+ next.company = c;
54
+ }
55
+ const url = lines.find((l) => /^https?:\/\//i.test(l));
56
+ if (url && !form.source_url) next.source_url = url;
57
+ setForm(next);
58
+ };
40
59
  const [err, setErr] = useState("");
41
60
  const [msg, setMsg] = useState("");
42
61
  const load = async () => { setRows(await api.get("/api/postings")); if (id) setSel(await api.get(`/api/postings/${id}`)); };
@@ -47,6 +66,7 @@ export function Postings({ live, id }) {
47
66
  const r = await api.post("/api/postings", form);
48
67
  await api.post(`/api/postings/${r.posting_id}/scan`);
49
68
  setForm({ company: "", title: "", source_url: "", location: "", comp_text: "", raw_text: "" });
69
+ setGuessed({ company: true, title: true });
50
70
  location.hash = "#/postings/" + r.posting_id;
51
71
  setMsg(r.created ? "stored and scanned" : "already stored; scanned again");
52
72
  } catch (e2) { setErr(e2.message); }
@@ -61,10 +81,10 @@ export function Postings({ live, id }) {
61
81
  <div>
62
82
  <h2>Paste posting</h2>
63
83
  <form class="card" onSubmit=${store}>
64
- <div class="row"><input required placeholder="company" value=${form.company} onInput=${(e) => setForm({ ...form, company: e.target.value })} /><input required placeholder="title" value=${form.title} onInput=${(e) => setForm({ ...form, title: e.target.value })} /></div>
65
- <div class="row"><input placeholder="url" value=${form.source_url} onInput=${(e) => setForm({ ...form, source_url: e.target.value })} /><input placeholder="location" value=${form.location} onInput=${(e) => setForm({ ...form, location: e.target.value })} /><input placeholder="comp text" value=${form.comp_text} onInput=${(e) => setForm({ ...form, comp_text: e.target.value })} /></div>
66
- <textarea required placeholder="raw posting text, stored verbatim, now" value=${form.raw_text} onInput=${(e) => setForm({ ...form, raw_text: e.target.value })}></textarea>
67
- <div class="row"><button class="primary">Store + scan</button><span class="small muted">local, no model</span></div>
84
+ <textarea required placeholder="Paste the whole job posting here, exactly as the site shows it. Company and title fill in below from what you paste." value=${form.raw_text} onInput=${(e) => onPaste(e.target.value)}></textarea>
85
+ <div class="row"><input required placeholder="company (guessed from the paste; fix if wrong)" value=${form.company} onInput=${(e) => { setGuessed({ ...guessed, company: false }); setForm({ ...form, company: e.target.value }); }} /><input required placeholder="title (guessed; fix if wrong)" value=${form.title} onInput=${(e) => { setGuessed({ ...guessed, title: false }); setForm({ ...form, title: e.target.value }); }} /></div>
86
+ <div class="row"><input placeholder="link to the posting (optional)" value=${form.source_url} onInput=${(e) => setForm({ ...form, source_url: e.target.value })} /></div>
87
+ <div class="row"><button class="primary">Store + scan</button><span class="small muted">stored on this PC and scored locally; no AI is called for this step</span></div>
68
88
  </form>
69
89
  <h2>Stored</h2>
70
90
  <div class="card"><ul class="list">${rows.map((r) => html`<li key=${r.posting_id}><a href=${"#/postings/" + r.posting_id}>${r.company}: ${r.title}</a> <span class="tag">${r.status}</span> ${r.scoring_frozen ? html`<span class="tag passed">frozen</span>` : html`<span class="tag warn">unscored</span>`} ${r.application_id ? html`<a class="small" href=${"#/applications/" + r.application_id}>application</a>` : ""} ${r.taken_down_at ? html`<span class="tag warn">taken down</span>` : ""}</li>`)}${rows.length ? "" : html`<li class="muted">none yet</li>`}</ul></div>