diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1232ec1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (Ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.33.3 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm rebuild better-sqlite3 + - run: pnpm test + + desktop: + name: Desktop ${{ matrix.platform }} + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + strategy: + fail-fast: false + matrix: + include: + - platform: ubuntu-22.04 + args: --target x86_64-unknown-linux-gnu + rust_target: x86_64-unknown-linux-gnu + - platform: macos-latest + args: --target aarch64-apple-darwin + rust_target: aarch64-apple-darwin + - platform: windows-latest + args: --target x86_64-pc-windows-msvc + rust_target: x86_64-pc-windows-msvc + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.33.3 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - name: Install Linux WebKit deps + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust_target }} + - uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/desktop/src-tauri -> target + - run: pnpm install --frozen-lockfile + - run: pnpm rebuild better-sqlite3 + - name: Bundle API sidecar + Node + run: node apps/desktop/scripts/bundle-api-sidecar.mjs --node + env: + PRM_BUNDLE_NODE: "1" + - uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + projectPath: apps/desktop + args: ${{ matrix.args }} + includeUpdaterJson: false diff --git a/.gitignore b/.gitignore index 553d803..830f26d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ data/ apps/ui/dist apps/api/dist apps/desktop/src-tauri/target +apps/desktop/src-tauri/sidecars/ diff --git a/apps/api/src/faithfulness.test.ts b/apps/api/src/faithfulness.test.ts new file mode 100644 index 0000000..e723132 --- /dev/null +++ b/apps/api/src/faithfulness.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { mergeLintFindings, runFaithfulnessLint } from "../src/faithfulness.js"; + +describe("faithfulness lint", () => { + it("flags unknown citations", () => { + const findings = runFaithfulnessLint("Shipped [ach_real] and [ach_fake].", ["ach_real"]); + assert.ok(findings.some((f) => f.category === "invention" && /ach_fake/.test(f.message))); + }); + + it("flags ungrounded long sentences", () => { + const text = [ + "They completely transformed the platform architecture across every squad this half.", + "Customer satisfaction improved dramatically while latency dropped for all regions.", + "See [ach_1] for the migration they led.", + ].join(" "); + const findings = runFaithfulnessLint(text, ["ach_1"]); + assert.ok(findings.some((f) => f.id === "ungrounded_claims")); + }); + + it("flags bare metrics", () => { + const findings = runFaithfulnessLint("Reduced p99 latency by 40% across checkout.", []); + assert.ok(findings.some((f) => f.category === "invention" && /40%/.test(f.message))); + }); + + it("merges findings without dupes", () => { + const a = runFaithfulnessLint("Reduced cost by 25% somehow.", []); + const merged = mergeLintFindings(a, a); + assert.equal(merged.length, a.length); + }); +}); diff --git a/apps/api/src/faithfulness.ts b/apps/api/src/faithfulness.ts new file mode 100644 index 0000000..2e400ea --- /dev/null +++ b/apps/api/src/faithfulness.ts @@ -0,0 +1,90 @@ +import type { BiasFinding } from "@prm/shared"; +import { extractCitationIds } from "./citations.js"; + +const SENTENCE_SPLIT = /(?<=[.!?])\s+(?=[A-Z("[])|(?:\n+)/; + +/** Claims that look substantive but lack any [id] citation nearby. */ +export function runFaithfulnessLint(text: string, allowlist: string[] = []): BiasFinding[] { + const findings: BiasFinding[] = []; + const body = text.trim(); + if (!body) return findings; + + const allowed = new Set(allowlist); + const cited = extractCitationIds(body); + for (const id of cited) { + if (allowlist.length && !allowed.has(id)) { + findings.push({ + id: `unknown_cite_${id}`, + severity: "warn", + category: "invention", + message: `Citation [${id}] is not in the evidence allowlist — remove or replace with a real evidence id.`, + excerpt: `[${id}]`, + }); + } + } + + const sentences = body + .split(SENTENCE_SPLIT) + .map((s) => s.trim()) + .filter((s) => s.length > 40); + + let ungrounded = 0; + const samples: string[] = []; + for (const sentence of sentences) { + if (/^(summary|strengths|growth|overall|note|private)\b/i.test(sentence)) continue; + if (!/\[[a-z]+_[A-Za-z0-9_-]+\]/.test(sentence)) { + // Skip questions / instructions + if (/\?$/.test(sentence) || /^(please|consider|remember)\b/i.test(sentence)) continue; + // Skip name-ish lists (mostly Title Case tokens, almost no verbs/stopwords) + const words = sentence.split(/\s+/).filter(Boolean); + const titleish = words.filter((w) => /^[A-Z][a-z]+/.test(w.replace(/[.,]/g, ""))).length; + if (words.length >= 3 && titleish / words.length >= 0.7) continue; + ungrounded += 1; + if (samples.length < 3) samples.push(sentence.slice(0, 120)); + } + } + + if (ungrounded >= 2) { + findings.push({ + id: "ungrounded_claims", + severity: "warn", + category: "invention", + message: `${ungrounded} long sentence(s) lack evidence citations. Prefer claims tied to [ach_…]/[fb_…]/[goal_…] ids.`, + excerpt: samples[0], + }); + } + + // Numeric claims without citations are high invention risk (% is non-word — no trailing \b) + const metricClaims = [ + ...body.matchAll(/(\d+%|\$\d[\d,]*(?:\.\d+)?|\b\d{2,}\s*(?:users|PRs|tickets|customers)\b)/gi), + ]; + for (const m of metricClaims.slice(0, 5)) { + const idx = m.index ?? 0; + const window = body.slice(Math.max(0, idx - 80), idx + 80); + if (!/\[[a-z]+_[A-Za-z0-9_-]+\]/.test(window)) { + findings.push({ + id: `metric_${idx}`, + severity: "warn", + category: "invention", + message: `Metric “${m[0]}” appears without a nearby evidence citation — verify or cite the source.`, + excerpt: m[0], + }); + } + } + + return findings; +} + +export function mergeLintFindings(...groups: BiasFinding[][]): BiasFinding[] { + const seen = new Set(); + const out: BiasFinding[] = []; + for (const group of groups) { + for (const f of group) { + const key = `${f.category}:${f.id}:${f.message}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(f); + } + } + return out; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 81825e3..6e8146d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -25,6 +25,7 @@ import { roleFrameworkVersions, roleFrameworks, workspace, + concernLogs, } from "@prm/db"; import type { BiasFinding, @@ -36,6 +37,8 @@ import type { RoleDraftDTO, RoleFrameworkInfo, WorkspaceStatus, + ConcernLogDTO, + DeadlineNudge, } from "@prm/shared"; import { COMPETENCY_CATALOG, MIDDLE_BAND, competencyLabel } from "@prm/shared"; import { seedWorkspace } from "./seed.js"; @@ -59,6 +62,9 @@ import { readDocumentFile, exportAuditPack, verifyActivityChain, + createEncryptedBackup, + listEncryptedBackups, + restoreEncryptedBackup, } from "./store.js"; import { buildChatSystemPrompt, @@ -72,6 +78,7 @@ import { } from "./ai.js"; import { extractCitationIds, filterCitationsAgainstAllowlist } from "./citations.js"; import { runBiasToneLint } from "./bias.js"; +import { mergeLintFindings, runFaithfulnessLint } from "./faithfulness.js"; import { renderPromotionPacketHtml, renderSubjectPacketHtml } from "./packetHtml.js"; import { localPeerSynthesize } from "./peerSynth.js"; import { @@ -312,6 +319,32 @@ app.post("/api/workspace/lock", (c) => { return c.json({ ok: true, encryptedAtRest: true }); }); +app.get("/api/workspace/backups", (c) => { + return c.json({ backups: listEncryptedBackups() }); +}); + +app.post("/api/workspace/backup", async (c) => { + const body = await c.req.json<{ label?: string }>().catch(() => ({}) as { label?: string }); + try { + const result = createEncryptedBackup(body.label); + return c.json(result); + } catch (e) { + return c.json({ error: e instanceof Error ? e.message : "Backup failed" }, 500); + } +}); + +app.post("/api/workspace/restore", async (c) => { + const body = await c.req.json<{ filename: string; confirm?: boolean }>(); + if (!body.filename) return c.json({ error: "filename required" }, 400); + if (!body.confirm) return c.json({ error: "confirm: true required" }, 400); + try { + const result = restoreEncryptedBackup(body.filename); + return c.json(result); + } catch (e) { + return c.json({ error: e instanceof Error ? e.message : "Restore failed" }, 500); + } +}); + app.get("/api/people", (c) => { const db = getDb(); const rows = db.select().from(people).all(); @@ -908,6 +941,9 @@ app.post("/api/cycles", async (c) => { windowStart: string; windowEnd: string; includeDirects?: boolean; + selfDue?: string; + peerDue?: string; + managerDue?: string; }>(); const db = getDb(); const cycleId = id("cycle"); @@ -933,7 +969,14 @@ app.post("/api/cycles", async (c) => { roleDefinitionId: a?.roleDefinitionId ?? null, targetNextRoleDefinitionId: a?.targetNextRoleDefinitionId ?? null, eligibility: "full", - phaseStatusJson: JSON.stringify({ self: "pending", peer: "pending", manager: "draft" }), + phaseStatusJson: JSON.stringify({ + self: "pending", + peer: "pending", + manager: "draft", + selfDue: body.selfDue ?? body.windowEnd, + peerDue: body.peerDue ?? body.windowEnd, + managerDue: body.managerDue ?? body.windowEnd, + }), }).run(); } } @@ -941,6 +984,119 @@ app.post("/api/cycles", async (c) => { return c.json({ id: cycleId }); }); +function readPhaseStatus(raw: string): Record { + try { + return JSON.parse(raw) as Record; + } catch { + return {}; + } +} + +function writePhaseStatus(participantId: string, patch: Record) { + const db = getDb(); + const row = db.select().from(cycleParticipants).where(eq(cycleParticipants.id, participantId)).all()[0]; + if (!row) return null; + const next = { ...readPhaseStatus(row.phaseStatusJson), ...patch }; + db.update(cycleParticipants) + .set({ phaseStatusJson: JSON.stringify(next) }) + .where(eq(cycleParticipants.id, participantId)) + .run(); + return next; +} + +function participantFor(cycleId: string, personId: string) { + const db = getDb(); + return db + .select() + .from(cycleParticipants) + .where(and(eq(cycleParticipants.cycleId, cycleId), eq(cycleParticipants.personId, personId))) + .all()[0]; +} + +app.patch("/api/cycles/:id", async (c) => { + const cycleId = c.req.param("id"); + const body = await c.req.json<{ + name?: string; + windowStart?: string; + windowEnd?: string; + status?: string; + }>(); + const db = getDb(); + const cycle = db.select().from(cycles).where(eq(cycles.id, cycleId)).all()[0]; + if (!cycle) return c.json({ error: "Not found" }, 404); + db.update(cycles) + .set({ + name: body.name ?? cycle.name, + windowStart: body.windowStart ?? cycle.windowStart, + windowEnd: body.windowEnd ?? cycle.windowEnd, + status: body.status ?? cycle.status, + }) + .where(eq(cycles.id, cycleId)) + .run(); + logActivity("cycle.update", "cycle", cycleId, body as Record); + return c.json({ ok: true }); +}); + +app.post("/api/cycles/:id/participants/:personId/waive", async (c) => { + const cycleId = c.req.param("id"); + const personId = c.req.param("personId"); + const body = await c.req.json<{ phase: string; reason: string }>(); + if (!body.reason?.trim()) return c.json({ error: "reason required" }, 400); + const phase = body.phase === "peer" ? "peer" : "self"; + const cp = participantFor(cycleId, personId); + if (!cp) return c.json({ error: "Participant not found" }, 404); + const phaseStatus = writePhaseStatus(cp.id, { + [phase]: "waived", + [`${phase}WaiveReason`]: body.reason.trim(), + [`${phase}WaivedAt`]: nowIso(), + }); + logActivity("cycle.waive", "person", personId, { cycleId, phase, reason: body.reason.trim() }); + return c.json({ ok: true, phaseStatus }); +}); + +app.get("/api/cycles/:id/nudges", (c) => { + const cycleId = c.req.param("id"); + const db = getDb(); + const cycle = db.select().from(cycles).where(eq(cycles.id, cycleId)).all()[0]; + if (!cycle) return c.json({ error: "Not found" }, 404); + const end = new Date(cycle.windowEnd); + const now = new Date(); + const daysUntilDue = Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + const participants = db.select().from(cycleParticipants).where(eq(cycleParticipants.cycleId, cycleId)).all(); + const nudges: DeadlineNudge[] = []; + for (const cp of participants) { + const person = db.select().from(people).where(eq(people.id, cp.personId)).all()[0]; + if (!person) continue; + const phase = readPhaseStatus(cp.phaseStatusJson); + const hasSelf = db + .select() + .from(reviews) + .where(and(eq(reviews.cycleId, cycleId), eq(reviews.subjectPersonId, cp.personId), eq(reviews.type, "self"))) + .all().length > 0; + const selfStatus = hasSelf ? "returned" : phase.self || "pending"; + if (selfStatus === "returned" || selfStatus === "waived") continue; + const subject = encodeURIComponent(`Self-review due: ${cycle.name}`); + const body = encodeURIComponent( + `Hi ${person.name},\n\nPlease complete your self-review for ${cycle.name} (due ${cycle.windowEnd}).\nYour manager will send a JSON bundle — fill it and return the file.\n\nThanks`, + ); + nudges.push({ + personId: person.id, + personName: person.name, + email: person.email, + cycleId, + cycleName: cycle.name, + windowEnd: cycle.windowEnd, + daysUntilDue, + missingSelf: !hasSelf, + selfStatus, + mailtoHref: person.email + ? `mailto:${encodeURIComponent(person.email)}?subject=${subject}&body=${body}` + : `mailto:?subject=${subject}&body=${body}`, + }); + } + return c.json({ cycleId, windowEnd: cycle.windowEnd, daysUntilDue, nudges }); +}); + app.get("/api/cycles/:id/command-center", (c) => { const db = getDb(); const cycleId = c.req.param("id"); @@ -948,14 +1104,33 @@ app.get("/api/cycles/:id/command-center", (c) => { const items = participants.map((cp) => { const person = db.select().from(people).where(eq(people.id, cp.personId)).all()[0]; const revs = db.select().from(reviews).where(and(eq(reviews.cycleId, cycleId), eq(reviews.subjectPersonId, cp.personId))).all(); + const phaseStatus = readPhaseStatus(cp.phaseStatusJson); + const hasSelf = revs.some((r) => r.type === "self"); + const selfStatus = hasSelf + ? "returned" + : phaseStatus.self === "waived" + ? "waived" + : phaseStatus.self === "exported" + ? "exported" + : "pending"; + const peerStatus = + phaseStatus.peer === "waived" + ? "waived" + : revs.filter((r) => r.type === "peer").length + ? "returned" + : phaseStatus.peer === "exported" + ? "exported" + : "pending"; return { participantId: cp.id, person: person ? mapPerson(person, assignmentFor(person.id)) : null, eligibility: cp.eligibility, - phaseStatus: JSON.parse(cp.phaseStatusJson) as Record, - hasSelf: revs.some((r) => r.type === "self"), + phaseStatus: { ...phaseStatus, self: selfStatus, peer: peerStatus }, + hasSelf, peerCount: revs.filter((r) => r.type === "peer").length, managerStatus: revs.find((r) => r.type === "manager")?.status ?? "missing", + selfStatus, + peerStatus, }; }); return c.json({ cycleId, items }); @@ -987,9 +1162,20 @@ app.get("/api/packets/:cycleId/:personId", (c) => { }); const flags: string[] = []; if (!currentRole) flags.push("unassigned_role"); - if (!revs.some((r) => r.type === "self")) flags.push("missing_self_import"); + const phaseStatus = participant ? readPhaseStatus(participant.phaseStatusJson) : {}; + const hasSelf = revs.some((r) => r.type === "self"); + if (!hasSelf && phaseStatus.self !== "waived") flags.push("missing_self_import"); if (!db.select().from(achievements).where(eq(achievements.personId, personId)).all().length) flags.push("thin_evidence"); + const cycle = db.select().from(cycles).where(eq(cycles.id, cycleId)).all()[0]; + const windowStart = cycle?.windowStart ?? "0000-01-01"; + const windowEnd = cycle?.windowEnd ?? "9999-12-31"; + const inWindow = (iso: string | null | undefined) => { + if (!iso) return true; + const day = iso.slice(0, 10); + return day >= windowStart.slice(0, 10) && day <= windowEnd.slice(0, 10); + }; + const manager = revs.find((r) => r.type === "manager" && r.status !== "superseded"); const links = manager ? db.select().from(evidenceLinks).where(eq(evidenceLinks.reviewId, manager.id)).all() @@ -1071,12 +1257,14 @@ app.get("/api/packets/:cycleId/:personId", (c) => { id: g.id, title: g.title, status: g.status, + createdAt: g.createdAt, })), achievements: achRows.map((row) => ({ id: row.id, title: row.title, occurredAt: row.occurredAt, description: row.description, + outOfWindow: !inWindow(row.occurredAt), })), feedback: fbRows.map((f) => ({ id: f.id, @@ -1084,12 +1272,32 @@ app.get("/api/packets/:cycleId/:personId", (c) => { kind: f.kind, body: f.body, occurredAt: f.occurredAt, + outOfWindow: !inWindow(f.occurredAt), })), documents: docRows.map((d) => ({ id: d.id, title: d.title, visibility: d.visibility as PacketDTO["documents"][number]["visibility"], + occurredAt: d.occurredAt, + outOfWindow: !inWindow(d.occurredAt), })), + outOfWindow: { + achievements: achRows + .filter((row) => !inWindow(row.occurredAt)) + .map((row) => ({ id: row.id, title: row.title, occurredAt: row.occurredAt })), + feedback: fbRows + .filter((f) => !inWindow(f.occurredAt)) + .map((f) => ({ + id: f.id, + fromName: f.fromName, + kind: f.kind, + body: f.body, + occurredAt: f.occurredAt, + })), + documents: docRows + .filter((d) => !inWindow(d.occurredAt)) + .map((d) => ({ id: d.id, title: d.title, occurredAt: d.occurredAt })), + }, evidenceLinks: links.map((l) => ({ id: l.id, targetType: l.targetType, @@ -1107,7 +1315,23 @@ app.get("/api/packets/:cycleId/:personId", (c) => { })), priorCycle, timeline: timeline.slice(0, 40), - flags, + flags: (() => { + const oow = + achRows.some((r) => !inWindow(r.occurredAt)) || + fbRows.some((f) => !inWindow(f.occurredAt)) || + docRows.some((d) => !inWindow(d.occurredAt)); + if (oow) flags.push("out_of_window_evidence"); + return flags; + })(), + phaseStatus, + writingSecondsTotal: (() => { + try { + const payload = JSON.parse(manager?.payloadJson ?? "{}") as Record; + return typeof payload.writing_seconds_total === "number" ? payload.writing_seconds_total : 0; + } catch { + return 0; + } + })(), }; return c.json(packet); }); @@ -1445,6 +1669,13 @@ app.post("/api/bundles/export", async (c) => { : "Author hidden in shared packet later — not cryptographically anonymous. Fill and return JSON to the manager.", }; logActivity("bundle.export", "person", body.subjectPersonId, { kind: body.kind }); + const cp = participantFor(body.cycleId, body.subjectPersonId); + if (cp) { + writePhaseStatus(cp.id, { + [body.kind]: "exported", + [`${body.kind}ExportedAt`]: nowIso(), + }); + } return c.json(bundle); }); @@ -1493,9 +1724,108 @@ app.post("/api/bundles/import", async (c) => { }).run(); logActivity("bundle.import", "review", reviewId, { kind }); + const cp = participantFor(cycleId, subjectPersonId); + if (cp) { + writePhaseStatus(cp.id, { + [kind]: "returned", + [`${kind}ReturnedAt`]: ts, + }); + } return c.json({ bundleId, reviewId }); }); +app.get("/api/people/:id/concerns", (c) => { + const personId = c.req.param("id"); + const db = getDb(); + const rows = db + .select() + .from(concernLogs) + .where(eq(concernLogs.personId, personId)) + .all() + .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); + const items: ConcernLogDTO[] = rows.map((r) => ({ + id: r.id, + personId: r.personId, + occurredAt: r.occurredAt, + title: r.title, + body: r.body, + expectationText: r.expectationText, + planText: r.planText, + status: r.status, + aiAllowed: Boolean(r.aiAllowed), + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })); + return c.json({ items }); +}); + +app.post("/api/people/:id/concerns", async (c) => { + const personId = c.req.param("id"); + const body = await c.req.json<{ + title: string; + body: string; + occurredAt?: string; + expectationText?: string; + planText?: string; + aiAllowed?: boolean; + }>(); + if (!body.title?.trim() || !body.body?.trim()) { + return c.json({ error: "title and body required" }, 400); + } + const db = getDb(); + const person = db.select().from(people).where(eq(people.id, personId)).all()[0]; + if (!person) return c.json({ error: "Not found" }, 404); + const ts = nowIso(); + const concernId = id("concern"); + db.insert(concernLogs) + .values({ + id: concernId, + personId, + occurredAt: body.occurredAt?.slice(0, 10) || ts.slice(0, 10), + title: body.title.trim(), + body: body.body.trim(), + expectationText: body.expectationText?.trim() || null, + planText: body.planText?.trim() || null, + status: "open", + aiAllowed: body.aiAllowed ? 1 : 0, + createdAt: ts, + updatedAt: ts, + }) + .run(); + logActivity("concern.create", "person", personId, { concernId, title: body.title.trim() }); + return c.json({ id: concernId }); +}); + +app.patch("/api/concerns/:id", async (c) => { + const concernId = c.req.param("id"); + const body = await c.req.json<{ + title?: string; + body?: string; + expectationText?: string | null; + planText?: string | null; + status?: string; + aiAllowed?: boolean; + }>(); + const db = getDb(); + const row = db.select().from(concernLogs).where(eq(concernLogs.id, concernId)).all()[0]; + if (!row) return c.json({ error: "Not found" }, 404); + db.update(concernLogs) + .set({ + title: body.title?.trim() || row.title, + body: body.body?.trim() || row.body, + expectationText: + body.expectationText === undefined ? row.expectationText : body.expectationText?.trim() || null, + planText: body.planText === undefined ? row.planText : body.planText?.trim() || null, + status: body.status ?? row.status, + aiAllowed: body.aiAllowed === undefined ? row.aiAllowed : body.aiAllowed ? 1 : 0, + updatedAt: nowIso(), + }) + .where(eq(concernLogs.id, concernId)) + .run(); + logActivity("concern.update", "person", row.personId, { concernId }); + return c.json({ ok: true }); +}); + app.get("/api/people/:id/dossier", (c) => { const personId = c.req.param("id"); const db = getDb(); @@ -2482,6 +2812,7 @@ app.post("/api/ai/bias-lint", async (c) => { text?: string; }>(); let text = body.text?.trim() ?? ""; + let allowlist: string[] = []; if (!text && body.cycleId && body.personId) { const db = getDb(); const manager = db @@ -2499,9 +2830,24 @@ app.post("/api/ai/bias-lint", async (c) => { if (manager) { const p = JSON.parse(manager.payloadJson) as Record; text = [p.summary, p.strengths, p.growth_areas, p.dev_plan, p.role_fit].map((x) => String(x ?? "")).join("\n\n"); + allowlist = db + .select() + .from(evidenceLinks) + .where(eq(evidenceLinks.reviewId, manager.id)) + .all() + .map((l) => l.sourceId); + allowlist.push( + ...db.select().from(achievements).where(eq(achievements.personId, body.personId)).all().map((a) => a.id), + ...db.select().from(goals).where(eq(goals.personId, body.personId)).all().map((g) => g.id), + ...db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, body.personId)).all().map((f) => f.id), + ...db.select().from(documents).where(eq(documents.personId, body.personId)).all().map((d) => d.id), + ); } } - const findings: BiasFinding[] = runBiasToneLint(text); + const findings = mergeLintFindings( + runBiasToneLint(text), + runFaithfulnessLint(text, allowlist), + ); return c.json({ findings, ephemeral: true }); }); diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts index 1812029..4ca8615 100644 --- a/apps/api/src/store.ts +++ b/apps/api/src/store.ts @@ -39,11 +39,13 @@ export const paths = { documents: join(ROOT, "files", "documents"), frameworks: join(ROOT, "files", "frameworks"), exports: join(ROOT, "files", "exports"), + backups: join(ROOT, "backups"), }; mkdirSync(paths.documents, { recursive: true }); mkdirSync(paths.frameworks, { recursive: true }); mkdirSync(paths.exports, { recursive: true }); +mkdirSync(paths.backups, { recursive: true }); type VaultMeta = { version: 1; @@ -357,6 +359,66 @@ export function encryptionStatus() { }; } +/** Snapshot unlocked SQLite (+ optional files note) into an encrypted backup under data/backups. */ +export function createEncryptedBackup(label?: string) { + const secret = getWorkspaceSecret(); + if (!secret) throw new Error("Workspace locked — unlock before backup"); + if (!existsSync(DB_PATH)) throw new Error("No plaintext database to back up"); + mkdirSync(paths.backups, { recursive: true }); + if (sqliteHandle) { + try { + sqliteHandle.pragma("wal_checkpoint(TRUNCATE)"); + } catch { + /* ignore */ + } + } + const stamp = nowIso().replace(/[:.]/g, "-"); + const safeLabel = (label ?? "manual").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 40); + const name = `prm-backup-${stamp}-${safeLabel}.sqlite.enc`; + const outPath = join(paths.backups, name); + const plain = readFileSync(DB_PATH); + writeFileSync(outPath, encryptBytes(plain, secret), { mode: 0o600 }); + logActivity("workspace.backup", "workspace", null, { filename: name, bytes: plain.length }); + return { filename: name, path: outPath, byteSize: plain.length, createdAt: nowIso() }; +} + +export function listEncryptedBackups() { + mkdirSync(paths.backups, { recursive: true }); + return readdirSync(paths.backups) + .filter((f) => f.endsWith(".sqlite.enc")) + .map((filename) => { + const full = join(paths.backups, filename); + const st = statSync(full); + return { filename, byteSize: st.size, modifiedAt: st.mtime.toISOString() }; + }) + .sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt)); +} + +/** + * Restore from an encrypted backup. Caller must unlock first; this replaces the live DB + * after closing handles — session remains but DB is reopened. + */ +export function restoreEncryptedBackup(filename: string) { + const secret = getWorkspaceSecret(); + if (!secret) throw new Error("Workspace locked — unlock before restore"); + const safe = basename(filename); + if (safe !== filename || !safe.endsWith(".sqlite.enc")) { + throw new Error("Invalid backup filename"); + } + const full = join(paths.backups, safe); + if (!existsSync(full)) throw new Error("Backup not found"); + const plain = decryptBytes(readFileSync(full), secret); + closeDbHandles(); + removeSidecars(); + writeFileSync(DB_PATH, plain, { mode: 0o600 }); + if (existsSync(DB_ENC_PATH)) unlinkSync(DB_ENC_PATH); + const opened = openDatabase(DB_PATH); + sqliteHandle = opened.sqlite; + dbHandle = opened.db; + logActivity("workspace.restore", "workspace", null, { filename: safe }); + return { ok: true as const, filename: safe }; +} + export function writeEncryptedDocument(filename: string, data: Buffer) { const secret = getWorkspaceSecret(); if (!secret) throw new Error("Workspace locked"); diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 178d47c..1003caa 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,6 +1,6 @@ # Desktop (Tauri) -Native shell for Linux / Windows / macOS wrapping the Vite UI. +Native shell for Linux / Windows / macOS wrapping the Vite UI and local API. ## Dev @@ -19,19 +19,28 @@ pnpm desktop:dev Browser-only `pnpm dev` does **not** set the launch token (middleware stays off). +Optional: set `PRM_SPAWN_API=1` so the shell also starts the API via `pnpm exec tsx` (useful when not using `desktop-dev.sh`). + ## Security slice - **CSP** is set in `tauri.conf.json` (connect limited to local API / Vite / Tauri IPC) - **Shell plugin removed** — no `shell:allow-open` / spawn from the webview - **Launch token** binds the UI webview to the local API when the desktop script (or sidecar) sets `PRM_LAUNCH_TOKEN` -## Production build +## Production build / API sidecar ```bash pnpm desktop:build ``` -Bundles the UI (`apps/ui/dist`). The local API still needs to be available on `127.0.0.1:8787` for this slice (sidecar packaging of the Node API is the next step). When you start the API for a packaged app, set the same `PRM_LAUNCH_TOKEN` as in `data/.launch_token` (or export it before launch). +`beforeBuildCommand`: + +1. Bundles `@prm/api` into `src-tauri/sidecars/prm-api.mjs` (esbuild) and copies a portable **Node** binary + `better-sqlite3` next to it +2. Builds the UI into `apps/ui/dist` + +At runtime the shell resolves `sidecars/` from the app resource dir (or `PRM_API_SIDECAR_DIR`), spawns bundled Node with the API entry, sets `PRM_LAUNCH_TOKEN` / `PRM_DATA_DIR`, and kills the child on exit — one process from the user’s point of view. + +CI builds the same matrix (Ubuntu / macOS / Windows) on pushes to `main`. ## Data diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c7fc955..4847b77 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,6 +9,7 @@ "build": "tauri build" }, "devDependencies": { - "@tauri-apps/cli": "^2.4.0" + "@tauri-apps/cli": "^2.4.0", + "esbuild": "^0.25.12" } } diff --git a/apps/desktop/scripts/bundle-api-sidecar.mjs b/apps/desktop/scripts/bundle-api-sidecar.mjs new file mode 100644 index 0000000..60817b4 --- /dev/null +++ b/apps/desktop/scripts/bundle-api-sidecar.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +/** + * Bundle @prm/api for Tauri to spawn (with optional portable Node). + * Output: apps/desktop/src-tauri/sidecars/ + * prm-api.mjs + * node(.exe) — when PRM_BUNDLE_NODE=1 or --node + * node_modules/better-sqlite3 (+ deps) for the native binding + */ +import { + mkdirSync, + writeFileSync, + cpSync, + rmSync, + chmodSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +import { execFileSync } from "node:child_process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, "../../.."); +const outDir = join(root, "apps/desktop/src-tauri/sidecars"); +const outFile = join(outDir, "prm-api.mjs"); +const bundleNode = + process.env.PRM_BUNDLE_NODE === "1" || process.argv.includes("--node"); + +mkdirSync(outDir, { recursive: true }); + +const require = createRequire(import.meta.url); + +function resolvePkg(name) { + try { + return dirname(require.resolve(`${name}/package.json`, { paths: [root, join(root, "apps/api"), join(root, "apps/desktop")] })); + } catch { + return null; + } +} + +function loadEsbuild() { + try { + return createRequire(join(root, "apps/desktop/package.json"))("esbuild"); + } catch { + try { + return require("esbuild"); + } catch { + return null; + } + } +} + +function copyNativeDeps() { + const nm = join(outDir, "node_modules"); + rmSync(nm, { recursive: true, force: true }); + mkdirSync(nm, { recursive: true }); + const sqlitePkg = resolvePkg("better-sqlite3"); + if (!sqlitePkg) { + console.warn("better-sqlite3 not found — sidecar will fail at runtime"); + return; + } + cpSync(sqlitePkg, join(nm, "better-sqlite3"), { recursive: true, dereference: true }); + const sqliteRequire = createRequire(join(sqlitePkg, "package.json")); + for (const name of ["bindings", "file-uri-to-path"]) { + try { + const src = dirname(sqliteRequire.resolve(`${name}/package.json`)); + cpSync(src, join(nm, name), { recursive: true, dereference: true }); + console.log("Copied dep", name, "→", join(nm, name)); + } catch (err) { + console.warn(`skip missing dep ${name}:`, err instanceof Error ? err.message : err); + } + } + writeFileSync( + join(outDir, "package.json"), + JSON.stringify({ type: "module", private: true, name: "prm-api-sidecar" }, null, 2), + ); +} + +function copyNodeBinary() { + const isWin = process.platform === "win32"; + const dest = join(outDir, isWin ? "node.exe" : "node"); + try { + cpSync(process.execPath, dest, { dereference: true }); + if (!isWin) chmodSync(dest, 0o755); + console.log("Copied Node binary:", dest); + } catch (err) { + console.warn("Could not copy Node binary:", err instanceof Error ? err.message : err); + } +} + +let esbuild = loadEsbuild(); + +if (!esbuild) { + // Dev-friendly launcher when esbuild isn't on the resolution path. + const launcher = `#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, "../../../.."); +const entry = join(root, "apps/api/src/index.ts"); +const child = spawn(process.execPath, ["--import", "tsx", entry], { + stdio: "inherit", + env: process.env, + cwd: join(root, "apps/api"), +}); +child.on("exit", (code) => process.exit(code ?? 1)); +`; + writeFileSync(outFile, launcher); + console.log("Wrote launcher sidecar (esbuild unavailable):", outFile); + if (bundleNode) copyNodeBinary(); + process.exit(0); +} + +await esbuild.build({ + entryPoints: [join(root, "apps/api/src/index.ts")], + bundle: true, + platform: "node", + format: "esm", + target: "node20", + outfile: outFile, + external: ["better-sqlite3"], + banner: { + js: [ + 'import { createRequire as __prmCreateRequire } from "module";', + "import { fileURLToPath as __prmFileURLToPath } from \"url\";", + "import { dirname as __prmDirname, join as __prmJoin } from \"path\";", + "const require = __prmCreateRequire(import.meta.url);", + "const __prmSidecarDir = __prmDirname(__prmFileURLToPath(import.meta.url));", + 'process.env.NODE_PATH = [__prmJoin(__prmSidecarDir, "node_modules"), process.env.NODE_PATH].filter(Boolean).join(process.platform === "win32" ? ";" : ":");', + ].join(""), + }, +}); +console.log("Bundled API sidecar:", outFile); +copyNativeDeps(); +if (bundleNode) copyNodeBinary(); + +// Touch mtime so Cargo/tauri notice resources changed +try { + execFileSync("node", ["-e", ""], { stdio: "ignore" }); +} catch { + /* ignore */ +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 21c044d..1095c0c 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -4,6 +4,7 @@ use std::{ fs, io::Read, path::{Path, PathBuf}, + process::{Child, Command, Stdio}, sync::Mutex, }; @@ -21,6 +22,7 @@ struct LaunchConfig { } struct LaunchState(Mutex); +struct ApiChild(Mutex>); #[tauri::command] fn get_launch_config(state: State<'_, LaunchState>) -> Result { @@ -85,9 +87,87 @@ fn resolve_launch_config() -> LaunchConfig { } } +fn candidate_sidecar_dirs(resource_dir: Option<&Path>) -> Vec { + let mut dirs = Vec::new(); + if let Ok(custom) = std::env::var("PRM_API_SIDECAR_DIR") { + dirs.push(PathBuf::from(custom)); + } + if let Some(res) = resource_dir { + dirs.push(res.join("sidecars")); + dirs.push(res.to_path_buf()); + } + dirs.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("sidecars")); + dirs +} + +/// Spawn the local Node API when a bundled sidecar exists or `PRM_SPAWN_API=1`. +fn maybe_spawn_api(token: &str, resource_dir: Option<&Path>) -> Option { + let force = std::env::var("PRM_SPAWN_API").ok().as_deref() == Some("1"); + let api_entry = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../api/src/index.ts"); + + let mut program: Option = None; + let mut args: Vec = Vec::new(); + let mut cwd: Option = None; + + for dir in candidate_sidecar_dirs(resource_dir) { + let script = dir.join("prm-api.mjs"); + if !script.exists() { + continue; + } + let bundled_node = if cfg!(windows) { + dir.join("node.exe") + } else { + dir.join("node") + }; + if bundled_node.exists() { + program = Some(bundled_node.to_string_lossy().to_string()); + } else { + program = Some("node".into()); + } + args = vec![script.to_string_lossy().to_string()]; + cwd = Some(dir); + break; + } + + if program.is_none() && force && api_entry.exists() { + program = Some("pnpm".into()); + args = vec!["exec".into(), "tsx".into(), "src/index.ts".into()]; + cwd = api_entry.parent()?.parent().map(|p| p.to_path_buf()); + } + + let (program, args, cwd) = match (program, cwd) { + (Some(p), Some(c)) => (p, args, c), + _ => return None, + }; + + let data = data_dir(); + let _ = fs::create_dir_all(&data); + match Command::new(&program) + .args(&args) + .current_dir(&cwd) + .env("PRM_LAUNCH_TOKEN", token) + .env("PRM_DATA_DIR", &data) + .env("PRM_PORT", "8787") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => { + eprintln!("PRM: spawned API sidecar ({program})"); + Some(child) + } + Err(err) => { + eprintln!("PRM: failed to spawn API sidecar: {err}"); + None + } + } +} + fn main() { let launch = resolve_launch_config(); std::env::set_var("PRM_LAUNCH_TOKEN", &launch.launch_token); + let token_for_spawn = launch.launch_token.clone(); let inject = format!( "window.__PRM_LAUNCH_TOKEN__={};", @@ -96,14 +176,22 @@ fn main() { tauri::Builder::default() .manage(LaunchState(Mutex::new(launch))) + .manage(ApiChild(Mutex::new(None))) .invoke_handler(tauri::generate_handler![get_launch_config]) .setup(move |app| { + let resource_dir = app.path().resource_dir().ok(); + let child = maybe_spawn_api(&token_for_spawn, resource_dir.as_deref()); + if let Some(state) = app.try_state::() { + if let Ok(mut guard) = state.0.lock() { + *guard = child; + } + } + let url = if cfg!(debug_assertions) { WebviewUrl::External(DEV_UI_URL.parse().expect("dev ui url")) } else { WebviewUrl::App("index.html".into()) }; - // Prefer initialization_script so the token exists before first fetch. if app.get_webview_window("main").is_none() { WebviewWindowBuilder::new(app, "main", url) .title("Performance Review Manager") @@ -116,6 +204,18 @@ fn main() { } Ok(()) }) - .run(tauri::generate_context!()) - .expect("error while running Performance Review Manager"); + .build(tauri::generate_context!()) + .expect("error while building Performance Review Manager") + .run(|app_handle, event| { + if let tauri::RunEvent::Exit = event { + if let Some(state) = app_handle.try_state::() { + if let Ok(mut guard) = state.0.lock() { + if let Some(mut child) = guard.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } + } + }); } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 0a24bdd..3ce8f79 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "build": { "beforeDevCommand": "bash ../scripts/desktop-dev.sh", "devUrl": "http://localhost:5173", - "beforeBuildCommand": "pnpm --dir ../.. --filter @prm/ui build", + "beforeBuildCommand": "node ../scripts/bundle-api-sidecar.mjs --node && pnpm --dir ../.. --filter @prm/ui build", "frontendDist": "../ui/dist" }, "app": { @@ -24,6 +24,9 @@ "icons/henry.w@example.net", "icons/icon.png" ], + "resources": [ + "sidecars/**/*" + ], "longDescription": "Solo engineering manager performance review workbench with encrypted local vault.", "shortDescription": "EM performance review workbench", "category": "Productivity" diff --git a/apps/ui/src/pages/CyclesPage.tsx b/apps/ui/src/pages/CyclesPage.tsx index ef27839..88a31b3 100644 --- a/apps/ui/src/pages/CyclesPage.tsx +++ b/apps/ui/src/pages/CyclesPage.tsx @@ -4,10 +4,22 @@ import type { CycleDTO, PersonDTO } from "@prm/shared"; import { api } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; +const PHASE_STATUSES = [ + { value: "self_open", label: "Self open" }, + { value: "peer_open", label: "Peer open" }, + { value: "manager_writing", label: "Manager writing" }, + { value: "closed", label: "Closed" }, +] as const; + export function CyclesPage() { const [cycles, setCycles] = useState([]); const [people, setPeople] = useState([]); const [name, setName] = useState("H2 2026"); + const [windowStart, setWindowStart] = useState("2026-07-01"); + const [windowEnd, setWindowEnd] = useState("2026-12-31"); + const [selfDue, setSelfDue] = useState(""); + const [peerDue, setPeerDue] = useState(""); + const [managerDue, setManagerDue] = useState(""); const [selectedCycle, setSelectedCycle] = useState(""); const [subjectId, setSubjectId] = useState(""); const [bundleKind, setBundleKind] = useState<"self" | "peer">("self"); @@ -29,6 +41,7 @@ export function CyclesPage() { }, []); const subject = people.find((p) => p.id === subjectId); + const selected = cycles.find((c) => c.id === selectedCycle); return (
@@ -77,7 +90,11 @@ export function CyclesPage() { NameWindowStatusPeople {cycles.map((c) => ( - + setSelectedCycle(c.id)} + > {c.name} {c.windowStart} → {c.windowEnd} {c.status} @@ -89,23 +106,110 @@ export function CyclesPage() { )}
{ e.preventDefault(); await api("/api/cycles", { method: "POST", - body: JSON.stringify({ name, windowStart: "2026-07-01", windowEnd: "2026-12-31", includeDirects: true }), + body: JSON.stringify({ + name, + windowStart, + windowEnd, + includeDirects: true, + ...(selfDue ? { selfDue } : {}), + ...(peerDue ? { peerDue } : {}), + ...(managerDue ? { managerDue } : {}), + }), }); setMessage("Cycle created — export self-review bundles next."); await load(); }} > - setName(e.target.value)} aria-label="Cycle name" /> - +
+ setName(e.target.value)} aria-label="Cycle name" placeholder="Cycle name" /> +
+ + setWindowStart(e.target.value)} required /> +
+
+ + setWindowEnd(e.target.value)} required /> +
+
+
+
+ + setSelfDue(e.target.value)} /> +
+
+ + setPeerDue(e.target.value)} /> +
+
+ + setManagerDue(e.target.value)} /> +
+ +
+ {selected && ( +
+

Phase wizard — {selected.name}

+

+ Soft gates only: advance phases when ready. Waive missing self/peer from the writing desk with a reason. +

+
+ {PHASE_STATUSES.map((phase) => ( + + ))} +
+
{ + e.preventDefault(); + const fd = new FormData(e.currentTarget); + await api(`/api/cycles/${selected.id}`, { + method: "PATCH", + body: JSON.stringify({ + windowStart: String(fd.get("windowStart") || selected.windowStart), + windowEnd: String(fd.get("windowEnd") || selected.windowEnd), + }), + }); + setMessage("Cycle window updated."); + await load(); + }} + > +
+ + +
+
+ + +
+ +
+
+ )} +

1. Export bundle

diff --git a/apps/ui/src/pages/HomePage.tsx b/apps/ui/src/pages/HomePage.tsx index d3ae945..033ef8e 100644 --- a/apps/ui/src/pages/HomePage.tsx +++ b/apps/ui/src/pages/HomePage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Link } from "react-router-dom"; -import type { CycleDTO, WorkspaceStatus } from "@prm/shared"; +import type { CycleDTO, DeadlineNudge, WorkspaceStatus } from "@prm/shared"; import { api } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; @@ -10,6 +10,8 @@ type CommandItem = { peerCount: number; managerStatus: string; phaseStatus: Record; + selfStatus?: string; + peerStatus?: string; }; function managerLabel(status: string) { @@ -18,11 +20,25 @@ function managerLabel(status: string) { return "not started"; } +function pipelinePill(status: string | undefined) { + const value = status || "pending"; + const tone = + value === "returned" || value === "waived" + ? "ok" + : value === "exported" + ? "warn" + : ""; + return {value}; +} + export function HomePage() { const [cycles, setCycles] = useState([]); const [items, setItems] = useState([]); const [cycleId, setCycleId] = useState(null); const [thinEvidenceCount, setThinEvidenceCount] = useState(0); + const [nudges, setNudges] = useState([]); + const [nudgeDays, setNudgeDays] = useState(null); + const [nudgeMsg, setNudgeMsg] = useState(null); useEffect(() => { api("/api/cycles").then((c) => { @@ -37,13 +53,26 @@ export function HomePage() { useEffect(() => { if (!cycleId) return; api<{ items: CommandItem[] }>(`/api/cycles/${cycleId}/command-center`).then((r) => setItems(r.items)); + api<{ nudges: DeadlineNudge[]; daysUntilDue: number }>(`/api/cycles/${cycleId}/nudges`) + .then((r) => { + setNudges(r.nudges); + setNudgeDays(r.daysUntilDue); + }) + .catch(() => { + setNudges([]); + setNudgeDays(null); + }); }, [cycleId]); const active = cycles.find((c) => c.id === cycleId); const stats = useMemo(() => { const unassigned = items.filter((i) => i.person && !i.person.roleTitle); - const needSelf = items.filter((i) => i.person && !i.hasSelf); + const needSelf = items.filter((i) => { + if (!i.person) return false; + const self = i.selfStatus ?? (i.hasSelf ? "returned" : "pending"); + return self !== "returned" && self !== "waived"; + }); const readyToWrite = items.filter( (i) => i.person && i.hasSelf && i.managerStatus !== "finalized" && i.managerStatus !== "shared", ); @@ -166,6 +195,45 @@ export function HomePage() { {active && {active.windowStart} → {active.windowEnd} · {active.status}}
+ {cycleId && nudges.length > 0 && ( +
+
+

Deadline nudges

+ +
+

+ {nudges.length} missing self-review + {nudgeDays != null ? ` · ${nudgeDays} day(s) until window end` : ""} +

+
    + {nudges.map((n) => ( +
  • + {n.personName}{" "} + ({n.selfStatus}){" "} + mailto +
  • + ))} +
+ {nudgeMsg &&

{nudgeMsg}

} +
+ )} +
{items.length === 0 ? (

@@ -178,7 +246,9 @@ export function HomePage() { Person Role Self + Self pipeline Peers + Peer pipeline Manager Your move @@ -188,7 +258,8 @@ export function HomePage() { const person = item.person; if (!person || !cycleId) return null; const needsRole = !person.roleTitle; - const needsSelf = !item.hasSelf; + const selfStatus = item.selfStatus ?? (item.hasSelf ? "returned" : "pending"); + const needsSelf = selfStatus !== "returned" && selfStatus !== "waived"; const finalized = item.managerStatus === "finalized" || item.managerStatus === "shared"; let cta: ReactNode; if (needsRole) { @@ -216,7 +287,9 @@ export function HomePage() { ? imported : missing} + {pipelinePill(selfStatus)} {item.peerCount} + {pipelinePill(item.peerStatus)} {managerLabel(item.managerStatus)} diff --git a/apps/ui/src/pages/PersonPage.tsx b/apps/ui/src/pages/PersonPage.tsx index 2a87373..dfae672 100644 --- a/apps/ui/src/pages/PersonPage.tsx +++ b/apps/ui/src/pages/PersonPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; -import type { CycleDTO, PersonDTO } from "@prm/shared"; +import type { ConcernLogDTO, CycleDTO, PersonDTO } from "@prm/shared"; import { api, getToken } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; @@ -23,12 +23,23 @@ export function PersonPage() { const [docFile, setDocFile] = useState(null); const [docMsg, setDocMsg] = useState(null); const [captureMsg, setCaptureMsg] = useState(null); + const [concerns, setConcerns] = useState([]); + const [concernTitle, setConcernTitle] = useState(""); + const [concernBody, setConcernBody] = useState(""); + const [concernExpectation, setConcernExpectation] = useState(""); + const [concernPlan, setConcernPlan] = useState(""); async function load() { if (!id) return; setPerson(await api(`/api/people/${id}`)); setDossier(await api(`/api/people/${id}/dossier`)); setCycles(await api("/api/cycles")); + try { + const res = await api<{ items: ConcernLogDTO[] }>(`/api/people/${id}/concerns`); + setConcerns(res.items); + } catch { + setConcerns([]); + } } useEffect(() => { @@ -234,6 +245,94 @@ export function PersonPage() { {captureMsg &&

{captureMsg}

} +
+

Concern log

+

+ Private performance concerns and follow-up plans. AI is off by default — these stay out of digests and drafts unless you later opt a concern in. +

+ {concerns.length === 0 ? ( +

No concerns logged.

+ ) : ( +
    + {concerns.map((c) => ( +
  • +
    + {c.title} + {c.status} +
    +

    {c.body}

    + {c.expectationText && ( +

    Expectation: {c.expectationText}

    + )} + {c.planText &&

    Plan: {c.planText}

    } + +
  • + ))} +
+ )} + { + e.preventDefault(); + if (!id) return; + await api(`/api/people/${id}/concerns`, { + method: "POST", + body: JSON.stringify({ + title: concernTitle, + body: concernBody, + expectationText: concernExpectation || undefined, + planText: concernPlan || undefined, + aiAllowed: false, + }), + }); + setConcernTitle(""); + setConcernBody(""); + setConcernExpectation(""); + setConcernPlan(""); + setCaptureMsg("Concern logged"); + await load(); + }} + > +
+ + setConcernTitle(e.target.value)} required /> +
+
+ +