diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43ca4ec..23ea014 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,11 +15,11 @@ jobs: name: Test (Ubuntu) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 with: version: 10.33.3 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 22 cache: pnpm @@ -49,11 +49,11 @@ jobs: artifact: desktop-windows-x86_64 runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 with: version: 10.33.3 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 22 cache: pnpm @@ -120,7 +120,7 @@ jobs: cat "$DATA/out.log" >&2 || true exit 1 - name: Upload desktop installers - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact }} path: apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle/ diff --git a/README.md b/README.md index cfa379a..ec74a41 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,12 @@ Regenerate with `pnpm screenshots` (UI + API running; default password `workbenc **Intent:** Answer "who is blocked, and what should I do next?" without hunting through every page. -**How it works:** Pick a cycle to see pipeline status (self / peer / manager), deadline nudges for missing self reviews, and a compact cycle dashboard (writing time, evidence coverage, thin dossiers). Links out to writing desks, backfill, and team performance when the numbers look thin. +**How it works:** Pick a cycle to see pipeline status (self / peer / manager), deadline nudges for missing self reviews, and a compact cycle dashboard (writing time, evidence coverage, thin dossiers). Links out to writing desks, backfill, and team performance when the numbers look thin. **Needs director attention** surfaces flags only (stagnant ratings, thin evidence, promo-ready, open concern counts) with a jump into consistency / director packs — no private note bodies. ![Command center](./docs/screenshots/02-home.png) +![Needs director attention](./docs/screenshots/15-director-attention.png) + ### Team **Intent:** Maintain the roster of directs you actually manage reviews for. @@ -133,36 +135,44 @@ Regenerate with `pnpm screenshots` (UI + API running; default password `workbenc **Intent:** Produce an evidence-backed manager review in a bounded amount of time (target ≤45 minutes per person). -**How it works:** Score competencies and overall against the assigned role, link dossier evidence to non-middle scores, draft narrative sections (optionally with AI from an allowlisted citation set), run bias / faithfulness lint, log decision reasons when ratings move, then finalize. Finalize freezes citation snapshots. Share exports HTML (print to PDF) with private notes and EM-only sources stripped. +**How it works:** Score competencies and overall against the assigned role, link dossier evidence to non-middle scores, draft narrative sections (optionally with AI from an allowlisted citation set), run bias / faithfulness lint, log decision reasons when ratings move, then finalize. Finalize freezes citation snapshots. Share exports HTML (print to PDF) with private notes and EM-only sources stripped. For upward share: **Skip-level talking points** (AI, forced achievement cites) and a per-person **Director card** HTML briefing. ![Writing desk](./docs/screenshots/07-writing-desk.png) +![Skip-level talking points and Director card](./docs/screenshots/16-writing-desk-director.png) + ### Promotions **Intent:** Reuse the same role bar for next-level and promotion cases instead of a parallel doc pile. -**How it works:** Open a promotion packet against a target RoleDefinition. Mark gaps / demonstrating signals, draft the case (optionally with AI), and keep it alongside the cycle review history so skip-level conversations stay evidence-backed. +**How it works:** Open a promotion packet against a target RoleDefinition. Mark gaps / demonstrating signals, draft the case (optionally with AI), and keep it alongside the cycle review history so skip-level conversations stay evidence-backed. After submit, **Promo committee export** prints an HTML packet with allowlisted cites and delivery themes (private / EM-only stripped). ![Promotions](./docs/screenshots/08-promotions.png) ![Promotion packet](./docs/screenshots/09-promotion-detail.png) +![Promo committee export](./docs/screenshots/19-promo-committee.png) + ### Consistency **Intent:** Catch unfair shape in your own drafts before anyone else sees them (everyone a 4, extreme scores with no cites, missing roles). -**How it works:** For a selected cycle, show overall rating distribution and a card per person with competencies, evidence link counts, and flags (thin evidence, extreme overall, unassigned role). Jump into each writing desk to fix gaps. This is single-EM calibration, not a multi-manager room. +**How it works:** For a selected cycle, show overall rating distribution and a card per person with competencies, evidence link counts, and flags (thin evidence, extreme overall, unassigned role). Jump into each writing desk to fix gaps. This is single-EM calibration, not a multi-manager room. **Calibration bundles** add director handoff: print calib room pack, cycle director briefing, encrypted read-only `.prm.enc` bundle, plus JSON export/import for decision round-trip. ![Consistency](./docs/screenshots/10-consistency.png) +![Calibration bundles / director packs](./docs/screenshots/17-consistency-director-packs.png) + ### Team performance **Intent:** See the team's rating and coaching shape as graphs, not only as a queue of drafts. -**How it works:** Cycle-scoped charts for overall distribution, evidence vs overall, competency averages, promo readiness, writing-time buckets, level mix, and top 1:1 themes. Cross-cycle **rating trends** and **cycle compare** call out movers, repeat thin dossiers, and theme drift. The people table adds competency sparklines and prior-cycle overall deltas when history exists. +**How it works:** Cycle-scoped charts for overall distribution, evidence vs overall, competency averages, promo readiness, writing-time buckets, level mix, and top 1:1 themes. Cross-cycle **rating trends** and **cycle compare** call out movers, repeat thin dossiers, and theme drift. The people table adds competency sparklines and prior-cycle overall deltas when history exists. **Director briefing** opens a printable cycle pack for the skip-level conversation. ![Team performance](./docs/screenshots/11-team-performance.png) +![Director briefing from team performance](./docs/screenshots/18-team-director-briefing.png) + ### Sprint pulse **Intent:** Review the last sprint (or any date window) for coaching, not final ratings. @@ -201,6 +211,7 @@ Regenerate with `pnpm screenshots` (UI + API running; default password `workbenc - **People / roles / cycles** with self and peer JSON bundles - **Writing desk**: competencies, evidence links, citation freeze, HTML subject packets - **Consistency** and **team performance** views before you share (including cross-cycle trends and cycle compare) +- **Director / upward packs**: briefing HTML, calib room pack, promo committee export, home attention tray, skip-level talking points, encrypted read-only bundle - **Sprint pulse** for date-window coaching and evidence freshness - **Person timeline** on each dossier - **Bias / faithfulness lint** (advisory) on drafts diff --git a/apps/api/src/directorGather.ts b/apps/api/src/directorGather.ts new file mode 100644 index 0000000..8d7f9f2 --- /dev/null +++ b/apps/api/src/directorGather.ts @@ -0,0 +1,318 @@ +import { and, eq } from "drizzle-orm"; +import { + achievements, + citationSnapshots, + concernLogs, + cycleParticipants, + cycles, + documents, + evidenceLinks, + feedbackItems, + goals, + people, + reviews, +} from "@prm/db"; +import type { Db } from "@prm/db"; +import { MIDDLE_BAND, type DirectorAttentionDTO, type DirectorAttentionItem, type DirectorBriefingDTO } from "@prm/shared"; +import { buildDeliveryRollup } from "./deliveryRollup.js"; +import { + buildCitedBullets, + bumpDistribution, + emptyDistribution, +} from "./directorPacks.js"; +import { computeStagnationHints } from "./stagnation.js"; +import { nowIso } from "./store.js"; + +type RoleLoader = (id: string | null | undefined) => { title: string } | null | undefined; +type AssignmentLoader = (personId: string) => { roleDefinitionId?: string | null; roleTitle?: string | null } | null | undefined; + +function priorOverallFor(db: Db, personId: string, cycleId: string, windowStart: string): number | null { + const prior = db + .select() + .from(cycles) + .all() + .filter((c) => c.id !== cycleId && c.windowStart < windowStart) + .sort((a, b) => b.windowStart.localeCompare(a.windowStart))[0]; + if (!prior) return null; + const mgr = db + .select() + .from(reviews) + .where(and(eq(reviews.cycleId, prior.id), eq(reviews.subjectPersonId, personId), eq(reviews.type, "manager"))) + .all() + .find((r) => r.status !== "superseded"); + if (!mgr) return null; + try { + const p = JSON.parse(mgr.payloadJson) as Record; + return typeof p.overall === "number" ? p.overall : null; + } catch { + return null; + } +} + +function evidenceFlags(args: { + overall: number | null; + competencies: Record; + links: Array<{ targetType: string; targetKey: string | null }>; + thinDossier: boolean; + thinDelivery: string | null; +}): string[] { + const flags: string[] = []; + if (args.thinDossier) flags.push("thin_dossier"); + if (args.thinDelivery === "jira_rich_dossier_thin") flags.push("thin_delivery_vs_jira"); + if (args.thinDelivery === "dossier_rich_integrations_empty_window") flags.push("integrations_empty_window"); + const scored: Array<{ key: string; score: number }> = [ + ...Object.entries(args.competencies).map(([key, score]) => ({ key, score })), + ...(args.overall != null ? [{ key: "overall", score: args.overall }] : []), + ]; + let gaps = 0; + for (const s of scored) { + if (s.score === MIDDLE_BAND) continue; + const has = args.links.some( + (l) => + (s.key === "overall" && l.targetType === "overall") || + (l.targetType === "competency" && l.targetKey === s.key), + ); + if (!has) { + gaps += 1; + if (s.key === "overall") flags.push("missing_evidence"); + else flags.push(`thin:${s.key}`); + } + } + if (args.overall != null && (args.overall <= 2 || args.overall >= 4) && gaps > 0) { + flags.push("extreme_overall_thin"); + } + return [...new Set(flags)]; +} + +export function buildDirectorBriefing( + db: Db, + opts: { + cycleId: string; + personId?: string | null; + loadRole: RoleLoader; + assignmentFor: AssignmentLoader; + }, +): DirectorBriefingDTO | null { + const cycle = db.select().from(cycles).where(eq(cycles.id, opts.cycleId)).all()[0]; + if (!cycle) return null; + const participants = db + .select() + .from(cycleParticipants) + .where(eq(cycleParticipants.cycleId, cycle.id)) + .all() + .filter((p) => !opts.personId || p.personId === opts.personId); + + const distribution = emptyDistribution(); + const peopleOut: DirectorBriefingDTO["people"] = []; + + for (const cp of participants) { + const person = db.select().from(people).where(eq(people.id, cp.personId)).all()[0]; + if (!person) continue; + const a = opts.assignmentFor(person.id); + const role = opts.loadRole(cp.roleDefinitionId ?? a?.roleDefinitionId); + const manager = db + .select() + .from(reviews) + .where(and(eq(reviews.cycleId, cycle.id), eq(reviews.subjectPersonId, person.id), eq(reviews.type, "manager"))) + .all() + .find((r) => r.status !== "superseded"); + + let overall: number | null = null; + let competencies: Record = {}; + let promoReadiness: string | null = null; + let summary = ""; + let strengths = ""; + let growth = ""; + let managerStatus = "missing"; + let links: Array<{ targetType: string; targetKey: string | null }> = []; + let citeIds: string[] = []; + + if (manager) { + managerStatus = manager.status; + try { + const payload = JSON.parse(manager.payloadJson) as Record; + // Share-safe: never include private_notes + overall = typeof payload.overall === "number" ? payload.overall : null; + competencies = (payload.competencies ?? {}) as Record; + promoReadiness = typeof payload.promo_readiness === "string" ? payload.promo_readiness : null; + summary = String(payload.summary ?? ""); + strengths = String(payload.strengths ?? ""); + growth = String(payload.growth_areas ?? ""); + } catch { + /* ignore */ + } + links = db + .select() + .from(evidenceLinks) + .where(eq(evidenceLinks.reviewId, manager.id)) + .all() + .map((l) => ({ targetType: l.targetType, targetKey: l.targetKey })); + citeIds = db + .select() + .from(citationSnapshots) + .where(eq(citationSnapshots.reviewId, manager.id)) + .all() + .map((s) => s.sourceId); + } + + const achRows = db.select().from(achievements).where(eq(achievements.personId, person.id)).all(); + const shareableAch = achRows.filter((a) => !a.sourceProvider || a.sourceProvider); + if (!citeIds.length) { + citeIds = shareableAch + .filter((a) => { + const d = a.occurredAt.slice(0, 10); + return d >= cycle.windowStart.slice(0, 10) && d <= cycle.windowEnd.slice(0, 10); + }) + .slice(0, 8) + .map((a) => a.id); + } + + const fb = db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, person.id)).all(); + const docs = db.select().from(documents).where(eq(documents.personId, person.id)).all(); + const goalsN = db.select().from(goals).where(eq(goals.personId, person.id)).all().length; + // Count only shareable dossier items for "thin" (exclude em_only) + const shareableFb = fb.filter((f) => f.visibility !== "em_only").length; + const shareableDocs = docs.filter((d) => d.visibility !== "em_only").length; + const dossierNonIntegration = + achRows.filter((a) => !a.sourceProvider).length + shareableFb + shareableDocs + goalsN; + const evidenceCount = achRows.length + shareableFb + shareableDocs + goalsN; + const rollup = buildDeliveryRollup({ + personId: person.id, + cycleId: cycle.id, + windowStart: cycle.windowStart.slice(0, 10), + windowEnd: cycle.windowEnd.slice(0, 10), + achievements: achRows, + dossierNonIntegrationCount: dossierNonIntegration, + }); + + const openConcernCount = db + .select() + .from(concernLogs) + .where(eq(concernLogs.personId, person.id)) + .all() + .filter((r) => r.status === "open").length; + + const priorOverall = priorOverallFor(db, person.id, cycle.id, cycle.windowStart); + const flags = evidenceFlags({ + overall, + competencies, + links, + thinDossier: evidenceCount < 2, + thinDelivery: rollup.flags.thinDeliveryEvidence, + }); + if (openConcernCount > 0) flags.push(`open_concerns:${openConcernCount}`); + + bumpDistribution(distribution, overall); + peopleOut.push({ + personId: person.id, + personName: person.name, + title: person.title, + roleTitle: role?.title ?? a?.roleTitle ?? null, + overall, + priorOverall, + delta: overall != null && priorOverall != null ? overall - priorOverall : null, + competencies, + promoReadiness, + citedBullets: buildCitedBullets({ summary, strengths, growth, citeIds, max: 8 }), + delivery: { + issuesDone: rollup.issuesDone, + prsMerged: rollup.prsMerged, + storyPointsDone: rollup.storyPointsDone, + reviewsGiven: rollup.reviewsGiven, + }, + flags, + openConcernCount, + managerStatus, + }); + } + + peopleOut.sort( + (a, b) => (b.overall ?? -1) - (a.overall ?? -1) || a.personName.localeCompare(b.personName), + ); + + return { + cycleId: cycle.id, + cycleName: cycle.name, + windowStart: cycle.windowStart.slice(0, 10), + windowEnd: cycle.windowEnd.slice(0, 10), + exportedAt: nowIso(), + notice: + "Director / skip-level briefing. Private manager notes and em_only dossier items are omitted. Concern bodies are not included — counts only. Not a subject-facing packet.", + people: peopleOut, + distribution, + }; +} + +export function buildDirectorAttention( + db: Db, + opts: { cycleId: string }, +): DirectorAttentionDTO | null { + const briefing = buildDirectorBriefing(db, { + cycleId: opts.cycleId, + loadRole: () => null, + assignmentFor: () => null, + }); + if (!briefing) return null; + + const stagRows: Array<{ + personId: string; + personName: string; + overall: number | null; + evidenceLinkCount: number; + cycleEnd: string; + }> = []; + for (const review of db.select().from(reviews).all()) { + if (review.type !== "manager") continue; + if (review.status !== "finalized" && review.status !== "shared") continue; + const person = db.select().from(people).where(eq(people.id, review.subjectPersonId)).all()[0]; + const cycle = db.select().from(cycles).where(eq(cycles.id, review.cycleId)).all()[0]; + if (!person || !cycle) continue; + const payload = JSON.parse(review.payloadJson) as Record; + stagRows.push({ + personId: person.id, + personName: person.name, + overall: typeof payload.overall === "number" ? payload.overall : null, + evidenceLinkCount: db.select().from(evidenceLinks).where(eq(evidenceLinks.reviewId, review.id)).all() + .length, + cycleEnd: cycle.windowEnd, + }); + } + const stagnantIds = new Set(computeStagnationHints(stagRows).map((h) => h.personId)); + + const items: DirectorAttentionItem[] = []; + for (const p of briefing.people) { + const reasons: string[] = []; + const stagnant = stagnantIds.has(p.personId); + const promoReady = p.promoReadiness === "ready"; + const thinDossier = p.flags.includes("thin_dossier"); + if (stagnant) reasons.push("stagnant_ratings"); + if (thinDossier) reasons.push("thin_dossier"); + if (p.flags.includes("thin_delivery_vs_jira")) reasons.push("jira_rich_dossier_thin"); + if (p.flags.includes("integrations_empty_window")) reasons.push("integrations_empty_window"); + if (promoReady) reasons.push("promo_ready"); + if (p.openConcernCount > 0) reasons.push("open_concerns"); + if (p.flags.includes("extreme_overall_thin") || p.flags.includes("missing_evidence")) { + reasons.push("evidence_gaps"); + } + if (!reasons.length) continue; + items.push({ + personId: p.personId, + personName: p.personName, + reasons, + overall: p.overall, + priorOverall: p.priorOverall, + promoReadiness: p.promoReadiness, + openConcernCount: p.openConcernCount, + thinDeliveryEvidence: p.flags.includes("thin_delivery_vs_jira") + ? "jira_rich_dossier_thin" + : p.flags.includes("integrations_empty_window") + ? "dossier_rich_integrations_empty_window" + : null, + thinDossier, + stagnant, + promoReady, + }); + } + items.sort((a, b) => b.reasons.length - a.reasons.length || a.personName.localeCompare(b.personName)); + return { cycleId: briefing.cycleId, cycleName: briefing.cycleName, items }; +} diff --git a/apps/api/src/directorPacks.test.ts b/apps/api/src/directorPacks.test.ts new file mode 100644 index 0000000..14fbd0e --- /dev/null +++ b/apps/api/src/directorPacks.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { buildCitedBullets, bumpDistribution, emptyDistribution, renderDirectorBriefingHtml } from "./directorPacks.js"; +import { sealReadonlyBundle } from "./readonlyBundle.js"; +import { decryptBytes } from "./store.js"; +import { createHash, scryptSync } from "node:crypto"; + +describe("director packs", () => { + it("builds cited bullets with forced ach ids", () => { + const bullets = buildCitedBullets({ + summary: "Shipped the billing migration on time with strong ownership across squads.", + strengths: "Coached two engineers through on-call readiness this half.", + citeIds: ["ach_1", "ach_2"], + max: 4, + }); + assert.ok(bullets.length >= 1); + assert.ok(bullets.every((b) => b.text.length > 10)); + assert.ok(bullets.some((b) => b.citeId === "ach_1" || b.citeId === "ach_2")); + }); + + it("renders director briefing html without private notes leakage", () => { + const html = renderDirectorBriefingHtml({ + cycleId: "c1", + cycleName: "H1 2026", + windowStart: "2026-01-01", + windowEnd: "2026-06-30", + exportedAt: "2026-07-01T00:00:00.000Z", + notice: "Director briefing test notice.", + distribution: { "1": 0, "2": 0, "3": 1, "4": 1, "5": 0, unset: 0 }, + people: [ + { + personId: "p1", + personName: "Ada", + title: "Engineer", + roleTitle: "IC4", + overall: 4, + priorOverall: 3, + delta: 1, + competencies: { execution: 4 }, + promoReadiness: "not_yet", + citedBullets: [{ text: "Led migration", citeId: "ach_1" }], + delivery: { issuesDone: 3, prsMerged: 2, storyPointsDone: 8, reviewsGiven: 1 }, + flags: ["thin_dossier"], + openConcernCount: 1, + managerStatus: "draft", + }, + ], + }); + assert.match(html, /Director briefing/); + assert.match(html, /Ada/); + assert.match(html, /\[ach_1\]/); + assert.doesNotMatch(html, /private_notes/i); + }); + + it("bumps distribution buckets", () => { + const d = emptyDistribution(); + bumpDistribution(d, 4); + bumpDistribution(d, null); + assert.equal(d["4"], 1); + assert.equal(d.unset, 1); + }); +}); + +describe("readonly bundle seal", () => { + it("encrypts with passphrase and recovers plaintext checksum", () => { + const plain = Buffer.from(JSON.stringify({ hello: "director" }), "utf8"); + const sealed = sealReadonlyBundle(plain, "test-pass-phrase"); + assert.ok(sealed.subarray(0, 6).equals(Buffer.from("PRM1RB"))); + const salt = sealed.subarray(6, 22); + const checksum = sealed.subarray(22, 30); + const enc = sealed.subarray(30); + const key = scryptSync("test-pass-phrase", Buffer.concat([Buffer.from("prm-readonly:"), salt]), 32); + const round = decryptBytes(enc, key); + assert.equal(round.toString("utf8"), plain.toString("utf8")); + assert.ok(checksum.equals(createHash("sha256").update(plain).digest().subarray(0, 8))); + }); +}); diff --git a/apps/api/src/directorPacks.ts b/apps/api/src/directorPacks.ts new file mode 100644 index 0000000..e89847c --- /dev/null +++ b/apps/api/src/directorPacks.ts @@ -0,0 +1,253 @@ +import { competencyLabel } from "@prm/shared"; +import type { DirectorBriefingDTO, DirectorBriefingPersonDTO } from "@prm/shared"; + +function esc(s: unknown) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +const PRINT_CSS = ` + :root { color-scheme: light; } + body { font-family: "Segoe UI", system-ui, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1.25rem 3rem; color: #0f172a; line-height: 1.55; } + h1 { font-size: 1.75rem; margin: 0 0 0.25rem; letter-spacing: -0.02em; } + h2 { font-size: 1.15rem; margin: 1.75rem 0 0.5rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.35rem; } + h3 { font-size: 1rem; margin: 1.1rem 0 0.35rem; } + .meta { color: #64748b; font-size: 0.9rem; margin-bottom: 1.25rem; } + .notice { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 0.85rem 1rem; font-size: 0.88rem; color: #334155; margin-bottom: 1rem; } + .card { border: 1px solid #e2e8f0; border-radius: 12px; padding: 1rem 1.1rem; margin: 1rem 0; page-break-inside: avoid; } + .flags span { display: inline-block; background: #fef3c7; color: #92400e; border-radius: 6px; padding: 0.1rem 0.45rem; font-size: 0.75rem; margin: 0 0.25rem 0.25rem 0; } + table { width: 100%; border-collapse: collapse; margin-top: 0.35rem; } + th, td { text-align: left; padding: 0.4rem 0.3rem; border-bottom: 1px solid #e2e8f0; font-size: 0.92rem; } + .muted { color: #94a3b8; font-size: 0.82rem; } + .metrics { display: flex; flex-wrap: wrap; gap: 0.75rem 1.25rem; margin: 0.5rem 0; } + .metrics div strong { display: block; font-size: 1.15rem; } + ul.bullets { margin: 0.35rem 0 0; padding-left: 1.15rem; } + .decision-row { display: grid; grid-template-columns: 1.4fr 0.6fr 1.4fr; gap: 0.5rem; border-bottom: 1px solid #e2e8f0; padding: 0.45rem 0; } + .decision-row .line { border-bottom: 1px solid #cbd5e1; min-height: 1.4rem; } + @media print { + body { margin: 0; max-width: none; } + .no-print { display: none !important; } + .card { break-inside: avoid; } + } +`; + +/** Build short share-safe bullets from narrative + cite ids (no private notes). */ +export function buildCitedBullets(args: { + summary?: string; + strengths?: string; + growth?: string; + citeIds: string[]; + max?: number; +}): Array<{ text: string; citeId: string | null }> { + const max = args.max ?? 8; + const citeIds = args.citeIds; + let citeIdx = 0; + const nextCite = () => { + const id = citeIds[citeIdx] ?? null; + if (citeIds.length) citeIdx = (citeIdx + 1) % citeIds.length; + return id; + }; + const chunks: string[] = []; + for (const block of [args.summary, args.strengths, args.growth]) { + if (!block?.trim()) continue; + for (const part of block.split(/(?<=[.!?])\s+/)) { + const t = part.trim(); + if (t.length < 24) continue; + chunks.push(t.replace(/\s+/g, " ").slice(0, 220)); + if (chunks.length >= max) break; + } + if (chunks.length >= max) break; + } + return chunks.slice(0, max).map((text) => { + const embedded = text.match(/\[(ach|fb|goal|doc)_[A-Za-z0-9_-]+\]/); + return { + text: text.replace(/\s*\[(ach|fb|goal|doc)_[A-Za-z0-9_-]+\]\s*/g, " ").trim(), + citeId: embedded ? embedded[0].slice(1, -1) : nextCite(), + }; + }); +} + +export function renderDirectorBriefingHtml(pack: DirectorBriefingDTO): string { + const distRows = ["5", "4", "3", "2", "1", "unset"] + .map((k) => { + const n = pack.distribution[k] ?? 0; + return `${esc(k === "unset" ? "—" : k)}${esc(n)}`; + }) + .join(""); + + const people = pack.people + .map((p) => { + const comps = Object.entries(p.competencies) + .map(([k, v]) => `${esc(competencyLabel(k))}${esc(v)}`) + .join(""); + const bullets = p.citedBullets + .map( + (b) => + `
  • ${esc(b.text)}${b.citeId ? ` [${esc(b.citeId)}]` : ""}
  • `, + ) + .join(""); + const flags = p.flags.map((f) => `${esc(f)}`).join(""); + const delta = p.delta == null ? "—" : p.delta > 0 ? `+${p.delta}` : String(p.delta); + return `
    +

    ${esc(p.personName)}

    +
    ${esc(p.roleTitle ?? p.title ?? "—")} · Overall ${esc(p.overall ?? "—")}/5 + ${p.priorOverall != null ? ` · Δ vs prior ${esc(delta)} (was ${esc(p.priorOverall)})` : ""} + ${p.promoReadiness ? ` · Promo: ${esc(p.promoReadiness)}` : ""} + · ${esc(p.managerStatus)} +
    + ${flags ? `
    ${flags}
    ` : ""} +
    +
    ${esc(p.delivery.issuesDone)}Issues done
    +
    ${esc(p.delivery.prsMerged)}PRs merged
    +
    ${esc(p.delivery.storyPointsDone ?? "—")}Story points
    +
    ${esc(p.openConcernCount)}Open concerns
    +
    + ${comps ? `${comps}
    CompetencyScore
    ` : ""} + ${bullets ? `

    Evidence highlights

      ${bullets}
    ` : `

    No shareable narrative bullets yet.

    `} +
    `; + }) + .join(""); + + return ` + + + +Director briefing — ${esc(pack.cycleName)} + + + +

    Tip: Print → Save as PDF. ${esc(pack.notice)}

    +

    Director briefing

    +
    ${esc(pack.cycleName)} · ${esc(pack.windowStart)} → ${esc(pack.windowEnd)} · ${esc(pack.people.length)} people · Exported ${esc(pack.exportedAt)}
    +
    ${esc(pack.notice)}
    +
    +

    Rating distribution

    + ${distRows}
    OverallCount
    +
    +
    +

    People

    + ${people || `

    No participants.

    `} +
    + +`; +} + +export function renderCalibRoomHtml(opts: { + cycleName: string; + exportedAt: string; + distribution: Record; + cards: Array<{ + personName: string; + roleTitle: string | null; + overall: number | null; + competencies: Record; + flags: string[]; + summaryExcerpt: string; + evidenceLinkCount: number; + evidenceGaps: number; + managerStatus: string; + }>; +}): string { + const dist = ["5", "4", "3", "2", "1", "unset"] + .map((k) => `${esc(k === "unset" ? "—" : k)}${esc(opts.distribution[k] ?? 0)}`) + .join(""); + const outliers = opts.cards.filter((c) => c.flags.length > 0 || c.evidenceGaps > 0 || c.overall == null); + const cards = opts.cards + .map((c) => { + const comps = Object.entries(c.competencies) + .map(([k, v]) => `${competencyLabel(k)} ${v}`) + .join(" · "); + const flags = c.flags.map((f) => `${esc(f)}`).join(""); + return `
    +

    ${esc(c.personName)}

    +
    ${esc(c.roleTitle ?? "—")} · Overall ${esc(c.overall ?? "—")} · ${esc(c.managerStatus)} + · ${esc(c.evidenceLinkCount)} cites${c.evidenceGaps ? ` · ${esc(c.evidenceGaps)} gaps` : ""}
    + ${flags ? `
    ${flags}
    ` : ""} + ${comps ? `

    ${esc(comps)}

    ` : ""} + ${c.summaryExcerpt ? `

    ${esc(c.summaryExcerpt)}

    ` : ""} +
    `; + }) + .join(""); + + const decisionRows = opts.cards + .map( + (c) => `
    +
    ${esc(c.personName)}
    was ${esc(c.overall ?? "—")}
    +
    New overall
    +
    Note
    +
    `, + ) + .join(""); + + return ` + + + +Calibration room — ${esc(opts.cycleName)} + + + +

    Print → Save as PDF for the director deck. Capture decisions below or apply them later in Consistency.

    +

    Calibration room pack

    +
    ${esc(opts.cycleName)} · ${esc(opts.cards.length)} cards · ${esc(outliers.length)} flagged · ${esc(opts.exportedAt)}
    +
    +

    Distribution

    + ${dist}
    OverallCount
    +
    +
    +

    Outliers & thin evidence

    + ${ + outliers.length + ? outliers + .map( + (c) => + `

    ${esc(c.personName)} — ${esc(c.flags.join(", ") || "evidence gaps")} (overall ${esc(c.overall ?? "—")})

    `, + ) + .join("") + : `

    No flagged outliers.

    ` + } +
    +
    +

    Candidate cards

    + ${cards} +
    +
    +

    Director decision sheet

    +

    Fill in the room, then enter decisions in Consistency → Apply director decisions (or import JSON).

    + ${decisionRows} +
    + +`; +} + +export function enhancePromotionCommitteeHtml(args: { + baseHtml: string; + citedEvidence: Array<{ title: string; citeId: string }>; + deliveryThemes: Array<{ theme: string; count: number }>; +}): string { + const cites = args.citedEvidence + .map((c) => `
  • ${esc(c.title)} [${esc(c.citeId)}]
  • `) + .join(""); + const themes = args.deliveryThemes + .map((t) => `
  • ${esc(t.theme)} (${esc(t.count)})
  • `) + .join(""); + const extra = `${ + cites ? `

    Allowlisted cites

      ${cites}
    ` : "" + }${themes ? `

    Delivery themes (cycle window)

      ${themes}
    ` : ""}`; + if (!extra) return args.baseHtml; + return args.baseHtml.replace("", `${extra}`); +} + +export function emptyDistribution(): Record { + return { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0, unset: 0 }; +} + +export function bumpDistribution(dist: Record, overall: number | null) { + if (overall != null && overall >= 1 && overall <= 5) dist[String(Math.round(overall))] += 1; + else dist.unset += 1; +} + +export type { DirectorBriefingPersonDTO }; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 15047bc..c5e46f2 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -97,6 +97,15 @@ import { mergeLintFindings, runFaithfulnessLint } from "./faithfulness.js"; import { registerP1Routes } from "./p1Routes.js"; import { indexReviewFts } from "./reviewSearch.js"; import { renderPromotionPacketHtml, renderSubjectPacketHtml } from "./packetHtml.js"; +import { + enhancePromotionCommitteeHtml, + renderCalibRoomHtml, + renderDirectorBriefingHtml, + bumpDistribution, + emptyDistribution, +} from "./directorPacks.js"; +import { buildDirectorAttention, buildDirectorBriefing } from "./directorGather.js"; +import { readonlyBundleFilename, sealReadonlyBundle } from "./readonlyBundle.js"; import { localPeerSynthesize } from "./peerSynth.js"; import { bitbucketSearchMembers, bitbucketTestConnection } from "./bitbucket.js"; import { githubSearchUsers, githubTestConnection } from "./github.js"; @@ -2896,6 +2905,79 @@ app.post("/api/ai/digest", async (c) => { }); }); +app.post("/api/ai/skip-level-talking-points", async (c) => { + const body = await c.req.json<{ cycleId: string; personId: string; useCloud?: boolean }>(); + const ctx = buildEvidenceContext(body.personId, body.cycleId); + const cfg = getAiConfig(); + const pack = buildDirectorBriefing(getDb(), { + cycleId: body.cycleId, + personId: body.personId, + loadRole, + assignmentFor, + }); + const person = pack?.people[0]; + const citeIds = person?.citedBullets.map((b) => b.citeId).filter(Boolean) as string[] ?? ctx.citations.slice(0, 8); + const forcedCites = citeIds.slice(0, 6).map((id) => `[${id}]`).join(" "); + + const localLines = [ + `Skip-level talking points — ${ctx.person?.name ?? "subject"} (${pack?.cycleName ?? body.cycleId}).`, + person + ? `Overall ${person.overall ?? "—"}/5${person.delta != null ? ` (Δ ${person.delta > 0 ? "+" : ""}${person.delta})` : ""}${person.promoReadiness ? ` · promo ${person.promoReadiness}` : ""}.` + : null, + person + ? `Delivery: ${person.delivery.issuesDone} issues done, ${person.delivery.prsMerged} PRs merged${person.delivery.storyPointsDone != null ? `, ${person.delivery.storyPointsDone} pts` : ""}.` + : null, + ...(person?.citedBullets.slice(0, 6).map((b) => `• ${b.text}${b.citeId ? ` [${b.citeId}]` : ""}`) ?? []), + forcedCites ? `Prefer cites: ${forcedCites}` : null, + person?.flags.length ? `Flags (share-safe): ${person.flags.join(", ")}` : null, + ].filter(Boolean) as string[]; + + let outputText: string; + let model = "local-grounded"; + if (body.useCloud !== false && cfg.enabled && (cfg.hasApiKey || cfg.localOnly || cfg.provider === "ollama")) { + try { + const result = await runChat("skip_level_talking_points", [ + { role: "system", content: buildSystemPrompt() }, + { + role: "user", + content: `Write 6–8 skip-level talking points for a 15-minute 1:1 with the EM's director about this person. +Rules: every substantive bullet MUST include an evidence cite like [ach_…]. Prefer Done-in-window delivery items. Omit private notes and concern bodies. Be candid but share-safe. + +Prefer these cites: ${forcedCites || "(use allowlisted ids from evidence)"} + +CONTEXT: +${ctx.lines.join("\n")}`, + }, + ]); + outputText = result.text; + model = result.model; + } catch (e) { + outputText = `${localLines.join("\n")}\n\n(Cloud draft failed: ${e instanceof Error ? e.message : "error"})`; + } + } else { + outputText = localLines.join("\n"); + } + + const filtered = filterCitationsAgainstAllowlist(outputText, ctx.citations); + outputText = filtered.text; + const generationId = storeGeneration({ + feature: "skip_level_talking_points", + subjectPersonId: body.personId, + cycleId: body.cycleId, + model, + outputText, + citations: filtered.citationsUsed, + dataClassesSent: ctx.dataClassesSent, + }); + return c.json({ + generationId, + outputText, + citations: filtered.citationsUsed, + citationsDropped: filtered.citationsDropped, + model, + }); +}); + app.post("/api/ai/draft-section", async (c) => { const body = await c.req.json<{ cycleId: string; @@ -3981,7 +4063,42 @@ app.get("/api/exports/promotion/:id", (c) => { : []; const exportedAt = nowIso(); const format = (c.req.query("format") ?? "html").toLowerCase(); - const html = renderPromotionPacketHtml({ + + const citeSnaps = review + ? db + .select() + .from(citationSnapshots) + .where(eq(citationSnapshots.reviewId, review.id)) + .all() + .map((s) => ({ title: s.title, citeId: s.sourceId })) + : []; + // Fall back to recent achievements as allowlisted cites for committee pack. + const achCites = + citeSnaps.length > 0 + ? citeSnaps + : db + .select() + .from(achievements) + .where(eq(achievements.personId, row.personId)) + .all() + .slice(0, 10) + .map((a) => ({ title: a.title, citeId: a.id })); + + const cycle = + db.select().from(cycles).all().sort((a, b) => b.windowEnd.localeCompare(a.windowEnd))[0] ?? null; + const achRows = db.select().from(achievements).where(eq(achievements.personId, row.personId)).all(); + const rollup = cycle + ? buildDeliveryRollup({ + personId: row.personId, + cycleId: cycle.id, + windowStart: cycle.windowStart.slice(0, 10), + windowEnd: cycle.windowEnd.slice(0, 10), + achievements: achRows, + dossierNonIntegrationCount: achRows.filter((a) => !a.sourceProvider).length, + }) + : null; + + let html = renderPromotionPacketHtml({ personName: person?.name ?? row.personId, currentRole: current?.title, targetRole: target?.title, @@ -3992,6 +4109,12 @@ app.get("/api/exports/promotion/:id", (c) => { assessments, exportedAt, }); + html = enhancePromotionCommitteeHtml({ + baseHtml: html, + citedEvidence: achCites, + deliveryThemes: rollup?.themes ?? [], + }); + if (format === "json") { return c.json({ personName: person?.name, @@ -3999,13 +4122,225 @@ app.get("/api/exports/promotion/:id", (c) => { targetRole: target?.title, payload, assessments, + citedEvidence: achCites, + deliveryThemes: rollup?.themes ?? [], exportedAt, }); } return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", - "content-disposition": `inline; filename="promo-${(person?.name ?? "case").replace(/\s+/g, "-").toLowerCase()}.html"`, + "content-disposition": `inline; filename="promo-committee-${(person?.name ?? "case").replace(/\s+/g, "-").toLowerCase()}.html"`, + }, + }); +}); + +app.get("/api/exports/director-briefing/:cycleId", (c) => { + const cycleId = c.req.param("cycleId"); + const personId = c.req.query("personId"); + const format = (c.req.query("format") ?? "html").toLowerCase(); + const pack = buildDirectorBriefing(getDb(), { + cycleId, + personId, + loadRole, + assignmentFor, + }); + if (!pack) return c.json({ error: "Cycle not found" }, 404); + logActivity("exports.director_briefing", "cycle", cycleId, { + personId: personId ?? null, + people: pack.people.length, + }); + if (format === "json") return c.json(pack); + const html = renderDirectorBriefingHtml(pack); + const name = personId + ? `director-briefing-${(pack.people[0]?.personName ?? "person").replace(/\s+/g, "-").toLowerCase()}.html` + : `director-briefing-${pack.cycleName.replace(/\s+/g, "-").toLowerCase()}.html`; + return new Response(html, { + headers: { + "content-type": "text/html; charset=utf-8", + "content-disposition": `inline; filename="${name}"`, + }, + }); +}); + +app.get("/api/exports/calib-room/:cycleId", (c) => { + const cycleId = c.req.param("cycleId"); + const format = (c.req.query("format") ?? "html").toLowerCase(); + const db = getDb(); + const cycle = db.select().from(cycles).where(eq(cycles.id, cycleId)).all()[0]; + if (!cycle) return c.json({ error: "Cycle not found" }, 404); + + const distribution = emptyDistribution(); + const cards: Array<{ + personName: string; + roleTitle: string | null; + overall: number | null; + competencies: Record; + flags: string[]; + summaryExcerpt: string; + evidenceLinkCount: number; + evidenceGaps: number; + managerStatus: string; + }> = []; + + for (const cp of db.select().from(cycleParticipants).where(eq(cycleParticipants.cycleId, cycle.id)).all()) { + const person = db.select().from(people).where(eq(people.id, cp.personId)).all()[0]; + if (!person) continue; + const a = assignmentFor(cp.personId); + const role = loadRole(cp.roleDefinitionId ?? a?.roleDefinitionId); + const manager = db + .select() + .from(reviews) + .where(and(eq(reviews.cycleId, cycle.id), eq(reviews.subjectPersonId, cp.personId), eq(reviews.type, "manager"))) + .all() + .find((r) => r.status !== "superseded"); + let overall: number | null = null; + let comps: Record = {}; + let summaryExcerpt = ""; + let flags: string[] = []; + let evidenceGaps = 0; + let evidenceLinkCount = 0; + let managerStatus = "missing"; + if (manager) { + managerStatus = manager.status; + const payload = JSON.parse(manager.payloadJson) as Record; + overall = typeof payload.overall === "number" ? payload.overall : null; + comps = (payload.competencies ?? {}) as Record; + summaryExcerpt = String(payload.summary ?? "").slice(0, 280); + const links = db.select().from(evidenceLinks).where(eq(evidenceLinks.reviewId, manager.id)).all(); + evidenceLinkCount = links.length; + const scored: Array<{ key: string; score: number }> = [ + ...Object.entries(comps).map(([key, score]) => ({ key, score })), + ...(overall != null ? [{ key: "overall", score: overall }] : []), + ]; + for (const s of scored) { + if (s.score === MIDDLE_BAND) continue; + const has = links.some( + (l) => + (s.key === "overall" && l.targetType === "overall") || + (l.targetType === "competency" && l.targetKey === s.key), + ); + if (!has) { + evidenceGaps += 1; + flags.push(s.key === "overall" ? "missing_evidence" : `thin:${s.key}`); + } + } + if (overall != null && (overall <= 2 || overall >= 4) && evidenceGaps > 0) flags.push("extreme_overall_thin"); + } + bumpDistribution(distribution, overall); + cards.push({ + personName: person.name, + roleTitle: role?.title ?? null, + overall, + competencies: comps, + flags, + summaryExcerpt, + evidenceLinkCount, + evidenceGaps, + managerStatus, + }); + } + + const exportedAt = nowIso(); + logActivity("exports.calib_room", "cycle", cycleId, { cards: cards.length }); + if (format === "json") { + return c.json({ cycleId, cycleName: cycle.name, exportedAt, distribution, cards }); + } + const html = renderCalibRoomHtml({ cycleName: cycle.name, exportedAt, distribution, cards }); + return new Response(html, { + headers: { + "content-type": "text/html; charset=utf-8", + "content-disposition": `inline; filename="calib-room-${cycle.name.replace(/\s+/g, "-").toLowerCase()}.html"`, + }, + }); +}); + +app.get("/api/director-attention", (c) => { + const cycleId = c.req.query("cycleId"); + const db = getDb(); + const cycle = cycleId + ? db.select().from(cycles).where(eq(cycles.id, cycleId)).all()[0] + : db.select().from(cycles).all().sort((a, b) => b.windowStart.localeCompare(a.windowStart))[0]; + if (!cycle) return c.json({ error: "No cycle found" }, 404); + const result = buildDirectorAttention(db, { cycleId: cycle.id }); + if (!result) return c.json({ error: "No cycle found" }, 404); + return c.json(result); +}); + +app.post("/api/exports/readonly-bundle", async (c) => { + const body = await c.req.json<{ + cycleId: string; + personId?: string; + passphrase: string; + include?: Array<"director_briefing" | "calib_room" | "promo">; + }>(); + if (!body.cycleId?.trim()) return c.json({ error: "cycleId required" }, 400); + if (!body.passphrase || body.passphrase.length < 8) { + return c.json({ error: "Passphrase must be at least 8 characters" }, 400); + } + const include = new Set(body.include?.length ? body.include : ["director_briefing", "calib_room"]); + const db = getDb(); + const cycle = db.select().from(cycles).where(eq(cycles.id, body.cycleId)).all()[0]; + if (!cycle) return c.json({ error: "Cycle not found" }, 404); + + const parts: Record = { + kind: "prm_readonly_bundle", + version: 1, + cycleId: cycle.id, + cycleName: cycle.name, + exportedAt: nowIso(), + notice: "Encrypted read-only handoff. Private notes and em_only items are omitted from included packs.", + }; + + if (include.has("director_briefing")) { + parts.directorBriefing = buildDirectorBriefing(db, { + cycleId: cycle.id, + personId: body.personId, + loadRole, + assignmentFor, + }); + parts.directorBriefingHtml = parts.directorBriefing + ? renderDirectorBriefingHtml(parts.directorBriefing as NonNullable) + : null; + } + if (include.has("calib_room")) { + // Reuse JSON shape from calib-room endpoint logic via briefing distribution + consistency-like cards + const briefing = buildDirectorBriefing(db, { cycleId: cycle.id, loadRole, assignmentFor }); + if (briefing) { + parts.calibRoom = { + cycleName: briefing.cycleName, + distribution: briefing.distribution, + cards: briefing.people.map((p) => ({ + personName: p.personName, + roleTitle: p.roleTitle, + overall: p.overall, + competencies: p.competencies, + flags: p.flags, + summaryExcerpt: p.citedBullets[0]?.text?.slice(0, 280) ?? "", + evidenceLinkCount: p.citedBullets.length, + evidenceGaps: p.flags.filter((f) => f.startsWith("thin:") || f === "missing_evidence").length, + managerStatus: p.managerStatus, + })), + }; + parts.calibRoomHtml = renderCalibRoomHtml({ + cycleName: briefing.cycleName, + exportedAt: briefing.exportedAt, + distribution: briefing.distribution, + cards: (parts.calibRoom as { cards: Parameters[0]["cards"] }).cards, + }); + } + } + + const plain = Buffer.from(JSON.stringify(parts), "utf8"); + const sealed = sealReadonlyBundle(plain, body.passphrase); + logActivity("exports.readonly_bundle", "cycle", cycle.id, { + bytes: sealed.byteLength, + include: [...include], + }); + return new Response(new Uint8Array(sealed), { + headers: { + "content-type": "application/octet-stream", + "content-disposition": `attachment; filename="${readonlyBundleFilename(cycle.name)}"`, }, }); }); diff --git a/apps/api/src/p1Routes.ts b/apps/api/src/p1Routes.ts index 03cd14a..4a76811 100644 --- a/apps/api/src/p1Routes.ts +++ b/apps/api/src/p1Routes.ts @@ -304,6 +304,25 @@ export function registerP1Routes(app: Hono, helpers: Helpers) { const links = manager ? db.select().from(evidenceLinks).where(eq(evidenceLinks.reviewId, manager.id)).all() : []; + const flags: string[] = []; + let evidenceGaps = 0; + const scored: Array<{ key: string; score: number }> = [ + ...Object.entries(comps).map(([key, score]) => ({ key, score })), + ...(overall != null ? [{ key: "overall", score: overall }] : []), + ]; + for (const s of scored) { + if (s.score === 3) continue; + const has = links.some( + (l) => + (s.key === "overall" && l.targetType === "overall") || + (l.targetType === "competency" && l.targetKey === s.key), + ); + if (!has) { + evidenceGaps += 1; + flags.push(s.key === "overall" ? "missing_evidence" : `thin:${s.key}`); + } + } + if (overall != null && (overall <= 2 || overall >= 4) && evidenceGaps > 0) flags.push("extreme_overall_thin"); cards.push({ personId: person.id, personName: person.name, @@ -312,9 +331,9 @@ export function registerP1Routes(app: Hono, helpers: Helpers) { overall, competencies: comps, evidenceLinkCount: links.length, - evidenceGaps: 0, + evidenceGaps, managerStatus: manager?.status ?? "missing", - flags: [], + flags, summaryExcerpt: String(payload.summary ?? "").slice(0, 280), }); } diff --git a/apps/api/src/readonlyBundle.ts b/apps/api/src/readonlyBundle.ts new file mode 100644 index 0000000..abd1f90 --- /dev/null +++ b/apps/api/src/readonlyBundle.ts @@ -0,0 +1,17 @@ +import { createHash, randomBytes, scryptSync } from "node:crypto"; +import { encryptBytes } from "./store.js"; + +const MAGIC = Buffer.from("PRM1RB"); // Performance Review Manager v1 read-only bundle + +/** Seal a JSON/HTML payload with a user-supplied passphrase (not the workspace vault key). */ +export function sealReadonlyBundle(plain: Buffer, passphrase: string): Buffer { + const salt = randomBytes(16); + const key = scryptSync(passphrase, Buffer.concat([Buffer.from("prm-readonly:"), salt]), 32); + const enc = encryptBytes(plain, key); + const checksum = createHash("sha256").update(plain).digest().subarray(0, 8); + return Buffer.concat([MAGIC, salt, checksum, enc]); +} + +export function readonlyBundleFilename(cycleName: string) { + return `prm-readonly-${cycleName.replace(/\s+/g, "-").toLowerCase()}.prm.enc`; +} diff --git a/apps/ui/src/lib/exports.ts b/apps/ui/src/lib/exports.ts new file mode 100644 index 0000000..c3f9c76 --- /dev/null +++ b/apps/ui/src/lib/exports.ts @@ -0,0 +1,47 @@ +import { apiUrl, getToken } from "./api"; + +/** Open an HTML export in a new tab (Print → PDF). */ +export async function openHtmlExport(path: string) { + const headers = new Headers(); + const token = getToken(); + if (token) headers.set("x-prm-token", token); + const launch = typeof window !== "undefined" ? window.__PRM_LAUNCH_TOKEN__?.trim() : ""; + if (launch) headers.set("x-prm-launch-token", launch); + const res = await fetch(apiUrl(path), { headers }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error((err as { error?: string }).error || "Export failed"); + } + const html = await res.text(); + const blob = new Blob([html], { type: "text/html" }); + const url = URL.createObjectURL(blob); + window.open(url, "_blank", "noopener,noreferrer"); + setTimeout(() => URL.revokeObjectURL(url), 60_000); +} + +/** Download a binary export (e.g. encrypted readonly bundle). */ +export async function downloadBinaryExport(path: string, init: RequestInit, fallbackName: string) { + const headers = new Headers(init.headers); + if (!headers.has("Content-Type") && !(init.body instanceof FormData)) { + headers.set("Content-Type", "application/json"); + } + const token = getToken(); + if (token) headers.set("x-prm-token", token); + const launch = typeof window !== "undefined" ? window.__PRM_LAUNCH_TOKEN__?.trim() : ""; + if (launch) headers.set("x-prm-launch-token", launch); + const res = await fetch(apiUrl(path), { ...init, headers }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error((err as { error?: string }).error || "Download failed"); + } + const blob = await res.blob(); + const cd = res.headers.get("content-disposition") ?? ""; + const match = /filename="([^"]+)"/.exec(cd); + const name = match?.[1] ?? fallbackName; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/apps/ui/src/pages/ConsistencyPage.tsx b/apps/ui/src/pages/ConsistencyPage.tsx index 850b591..c67dfed 100644 --- a/apps/ui/src/pages/ConsistencyPage.tsx +++ b/apps/ui/src/pages/ConsistencyPage.tsx @@ -3,6 +3,7 @@ import { Link, useSearchParams } from "react-router-dom"; import type { CalibBundleDTO, ConsistencyDTO, CycleDTO } from "@prm/shared"; import { competencyLabel } from "@prm/shared"; import { api } from "../lib/api"; +import { downloadBinaryExport, openHtmlExport } from "../lib/exports"; import { NextStep, PageHeader } from "../components/PageChrome"; export function ConsistencyPage() { @@ -16,6 +17,7 @@ export function ConsistencyPage() { const [directorDecisions, setDirectorDecisions] = useState< Record >({}); + const [bundlePass, setBundlePass] = useState(""); const cycleId = params.get("cycleId") ?? ""; async function load(cid?: string) { @@ -129,6 +131,71 @@ export function ConsistencyPage() { > Export calib JSON + + + +
    +
    + + setBundlePass(e.target.value)} + placeholder="min 8 characters" + /> +
    +
    {exportedBundle && ( diff --git a/apps/ui/src/pages/HomePage.tsx b/apps/ui/src/pages/HomePage.tsx index 710a0ec..7369648 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, CycleStatsDTO, DeadlineNudge, DueThisWeekItem, WorkspaceStatus } from "@prm/shared"; +import type { CycleDTO, CycleStatsDTO, DeadlineNudge, DirectorAttentionDTO, DueThisWeekItem, WorkspaceStatus } from "@prm/shared"; import { api } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; @@ -41,6 +41,7 @@ export function HomePage() { const [nudgeDays, setNudgeDays] = useState(null); const [nudgeMsg, setNudgeMsg] = useState(null); const [cycleStats, setCycleStats] = useState(null); + const [attention, setAttention] = useState(null); useEffect(() => { api("/api/cycles").then((c) => { @@ -105,6 +106,9 @@ export function HomePage() { api(`/api/cycles/${cycleId}/stats`) .then(setCycleStats) .catch(() => setCycleStats(null)); + api(`/api/director-attention?cycleId=${encodeURIComponent(cycleId)}`) + .then(setAttention) + .catch(() => setAttention(null)); }, [cycleId]); const active = cycles.find((c) => c.id === cycleId); @@ -238,6 +242,34 @@ export function HomePage() { {active && {active.windowStart} → {active.windowEnd} · {active.status}} + {attention && attention.items.length > 0 && ( +
    +

    Needs director attention

    +

    + Flags only — stagnant ratings, thin evidence, promo-ready, open concern counts. No private note bodies. +

    +
      + {attention.items.slice(0, 10).map((item) => ( +
    • + + {item.personName} + {" "} + + {item.reasons.join(" · ")} + {item.overall != null ? ` · overall ${item.overall}` : ""} + {" "} + + Desk + +
    • + ))} +
    + + Open consistency / director packs + +
    + )} + {dueWeek.length > 0 && (

    Due this week

    diff --git a/apps/ui/src/pages/PromotionDetailPage.tsx b/apps/ui/src/pages/PromotionDetailPage.tsx index 4eb5c3f..c55f252 100644 --- a/apps/ui/src/pages/PromotionDetailPage.tsx +++ b/apps/ui/src/pages/PromotionDetailPage.tsx @@ -110,7 +110,7 @@ export function PromotionDetailPage() { primary={ submitted ? ( ) : ( @@ -128,11 +128,11 @@ export function PromotionDetailPage() { Back to promotions diff --git a/apps/ui/src/pages/TeamPerformancePage.tsx b/apps/ui/src/pages/TeamPerformancePage.tsx index 0bd2bd4..ec1fd08 100644 --- a/apps/ui/src/pages/TeamPerformancePage.tsx +++ b/apps/ui/src/pages/TeamPerformancePage.tsx @@ -3,6 +3,7 @@ import { Link, useSearchParams } from "react-router-dom"; import type { CycleCompareDTO, CycleDTO, RatingTrendsDTO, TeamPerformanceDTO } from "@prm/shared"; import { competencyLabel } from "@prm/shared"; import { api } from "../lib/api"; +import { openHtmlExport } from "../lib/exports"; import { NextStep, PageHeader } from "../components/PageChrome"; const PROMO_LABELS: Record = { @@ -270,6 +271,13 @@ export function TeamPerformancePage() { primary={ data ? ( <> + Sprint pulse diff --git a/apps/ui/src/pages/WritingDeskPage.tsx b/apps/ui/src/pages/WritingDeskPage.tsx index b05436d..75cd25e 100644 --- a/apps/ui/src/pages/WritingDeskPage.tsx +++ b/apps/ui/src/pages/WritingDeskPage.tsx @@ -3,6 +3,7 @@ import { Link, useParams } from "react-router-dom"; import type { BiasFinding, DecisionLogEntry, DeliveryCiteSuggestion, GapMark, PacketDTO, PromoReadiness } from "@prm/shared"; import { COMPETENCY_CATALOG, MIDDLE_BAND, competencyLabel } from "@prm/shared"; import { api, getToken, apiUrl } from "../lib/api"; +import { openHtmlExport } from "../lib/exports"; import { NextStep, PageHeader } from "../components/PageChrome"; import { DeliveryRollupPanel } from "../components/DeliveryRollupPanel"; @@ -35,6 +36,7 @@ export function WritingDeskPage() { const [gaps, setGaps] = useState>({}); const [digest, setDigest] = useState(null); const [citeSuggestions, setCiteSuggestions] = useState([]); + const [skipLevel, setSkipLevel] = useState(null); const [peerSynth, setPeerSynth] = useState(null); const [status, setStatus] = useState(null); const [error, setError] = useState(null); @@ -518,8 +520,42 @@ export function WritingDeskPage() { > Generate digest + +
    {digest ?? "Generate a grounded digest from dossier + imports before drafting."}
    + {skipLevel && ( +
    +

    + Skip-level talking points (copy for a 15‑min with your director) +

    +
    +                  {skipLevel}
    +                
    +
    + )} {citeSuggestions.length > 0 && (

    diff --git a/docs/screenshots/01-unlock.png b/docs/screenshots/01-unlock.png index f2b0a55..c359da3 100644 Binary files a/docs/screenshots/01-unlock.png and b/docs/screenshots/01-unlock.png differ diff --git a/docs/screenshots/02-home.png b/docs/screenshots/02-home.png index 36d3136..7eecb04 100644 Binary files a/docs/screenshots/02-home.png and b/docs/screenshots/02-home.png differ diff --git a/docs/screenshots/03-team.png b/docs/screenshots/03-team.png index 3a7165d..8d5f5f5 100644 Binary files a/docs/screenshots/03-team.png and b/docs/screenshots/03-team.png differ diff --git a/docs/screenshots/04-person.png b/docs/screenshots/04-person.png index d218a3f..37cf5c5 100644 Binary files a/docs/screenshots/04-person.png and b/docs/screenshots/04-person.png differ diff --git a/docs/screenshots/05-roles.png b/docs/screenshots/05-roles.png index 64335ef..daebe95 100644 Binary files a/docs/screenshots/05-roles.png and b/docs/screenshots/05-roles.png differ diff --git a/docs/screenshots/06-cycles.png b/docs/screenshots/06-cycles.png index ae91fe5..3860f7e 100644 Binary files a/docs/screenshots/06-cycles.png and b/docs/screenshots/06-cycles.png differ diff --git a/docs/screenshots/07-writing-desk.png b/docs/screenshots/07-writing-desk.png index 90a87dd..ca8436d 100644 Binary files a/docs/screenshots/07-writing-desk.png and b/docs/screenshots/07-writing-desk.png differ diff --git a/docs/screenshots/08-promotions.png b/docs/screenshots/08-promotions.png index 71915cf..4c82b33 100644 Binary files a/docs/screenshots/08-promotions.png and b/docs/screenshots/08-promotions.png differ diff --git a/docs/screenshots/09-promotion-detail.png b/docs/screenshots/09-promotion-detail.png index b705b12..307c2aa 100644 Binary files a/docs/screenshots/09-promotion-detail.png and b/docs/screenshots/09-promotion-detail.png differ diff --git a/docs/screenshots/10-consistency.png b/docs/screenshots/10-consistency.png index 8205457..042110c 100644 Binary files a/docs/screenshots/10-consistency.png and b/docs/screenshots/10-consistency.png differ diff --git a/docs/screenshots/11-team-performance.png b/docs/screenshots/11-team-performance.png index d423408..05d9284 100644 Binary files a/docs/screenshots/11-team-performance.png and b/docs/screenshots/11-team-performance.png differ diff --git a/docs/screenshots/12-backfill.png b/docs/screenshots/12-backfill.png index 2e75724..abc6e7a 100644 Binary files a/docs/screenshots/12-backfill.png and b/docs/screenshots/12-backfill.png differ diff --git a/docs/screenshots/13-chat.png b/docs/screenshots/13-chat.png index 71cb8b0..fdeb254 100644 Binary files a/docs/screenshots/13-chat.png and b/docs/screenshots/13-chat.png differ diff --git a/docs/screenshots/14-settings.png b/docs/screenshots/14-settings.png index 1f75e0b..dd19b81 100644 Binary files a/docs/screenshots/14-settings.png and b/docs/screenshots/14-settings.png differ diff --git a/docs/screenshots/15-director-attention.png b/docs/screenshots/15-director-attention.png new file mode 100644 index 0000000..cdabcdd Binary files /dev/null and b/docs/screenshots/15-director-attention.png differ diff --git a/docs/screenshots/16-writing-desk-director.png b/docs/screenshots/16-writing-desk-director.png new file mode 100644 index 0000000..ca8436d Binary files /dev/null and b/docs/screenshots/16-writing-desk-director.png differ diff --git a/docs/screenshots/17-consistency-director-packs.png b/docs/screenshots/17-consistency-director-packs.png new file mode 100644 index 0000000..042110c Binary files /dev/null and b/docs/screenshots/17-consistency-director-packs.png differ diff --git a/docs/screenshots/18-team-director-briefing.png b/docs/screenshots/18-team-director-briefing.png new file mode 100644 index 0000000..05d9284 Binary files /dev/null and b/docs/screenshots/18-team-director-briefing.png differ diff --git a/docs/screenshots/19-promo-committee.png b/docs/screenshots/19-promo-committee.png new file mode 100644 index 0000000..307c2aa Binary files /dev/null and b/docs/screenshots/19-promo-committee.png differ diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index 0ba15b0..35cd6ce 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -12,3 +12,13 @@ pnpm screenshots Requires system Chrome (`google-chrome-stable`) and root `puppeteer-core`. Numbering matches the README Screens section (`01` unlock through `14` settings), including `11-team-performance`. + +Director / upward-pack close-ups (regenerated with the same script): + +| File | Surface | +| --- | --- | +| `15-director-attention.png` | Home — Needs director attention | +| `16-writing-desk-director.png` | Writing desk — Skip-level talking points + Director card | +| `17-consistency-director-packs.png` | Consistency — calib room / briefing / encrypted bundle | +| `18-team-director-briefing.png` | Team performance — Director briefing | +| `19-promo-committee.png` | Promotion detail — Promo committee export | diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 61db9a8..880c874 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -334,6 +334,60 @@ export interface StagnationHint { cycleCount: number; } +/** Flags-only queue for directors (no private note bodies). */ +export interface DirectorAttentionItem { + personId: string; + personName: string; + reasons: string[]; + overall: number | null; + priorOverall: number | null; + promoReadiness: string | null; + openConcernCount: number; + thinDeliveryEvidence: string | null; + thinDossier: boolean; + stagnant: boolean; + promoReady: boolean; +} + +export interface DirectorAttentionDTO { + cycleId: string; + cycleName: string; + items: DirectorAttentionItem[]; +} + +export interface DirectorBriefingPersonDTO { + personId: string; + personName: string; + title: string | null; + roleTitle: string | null; + overall: number | null; + priorOverall: number | null; + delta: number | null; + competencies: Record; + promoReadiness: string | null; + citedBullets: Array<{ text: string; citeId: string | null }>; + delivery: { + issuesDone: number; + prsMerged: number; + storyPointsDone: number | null; + reviewsGiven: number; + }; + flags: string[]; + openConcernCount: number; + managerStatus: string; +} + +export interface DirectorBriefingDTO { + cycleId: string; + cycleName: string; + windowStart: string; + windowEnd: string; + exportedAt: string; + notice: string; + people: DirectorBriefingPersonDTO[]; + distribution: Record; +} + export type IntegrationProvider = "jira" | "bitbucket" | "github"; export interface IntegrationSettingsDTO { diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs index 3212cd4..a05f889 100644 --- a/scripts/capture-screenshots.mjs +++ b/scripts/capture-screenshots.mjs @@ -27,10 +27,44 @@ async function unlockToken() { return json.token; } -async function apiJson(token, pathName) { - const res = await fetch(`${API}${pathName}`, { headers: { "x-prm-token": token } }); - if (!res.ok) throw new Error(`${pathName} → ${res.status}`); - return res.json(); +async function apiJson(token, pathName, init) { + const res = await fetch(`${API}${pathName}`, { + ...init, + headers: { + "x-prm-token": token, + ...(init?.body ? { "content-type": "application/json" } : {}), + ...(init?.headers || {}), + }, + }); + if (!res.ok) throw new Error(`${pathName} → ${res.status} ${await res.text()}`); + const text = await res.text(); + return text ? JSON.parse(text) : null; +} + +async function ensureSubmittedPromo(token, promos) { + const draft = promos.find((p) => p.status === "draft") || promos[0]; + if (!draft?.id) return draft; + if (draft.status === "submitted") return draft; + const detail = await apiJson(token, `/api/promotions/${draft.id}`); + const payload = detail?.review?.payload ?? {}; + await apiJson(token, `/api/promotions/${draft.id}`, { + method: "PUT", + body: JSON.stringify({ + status: "submitted", + decisionReason: "Screenshot gallery: show promo committee export", + payload: { + case_summary: payload.case_summary || "Ready for committee review with evidence-backed case.", + scope_examples: payload.scope_examples || "Led cross-team delivery on critical path work.", + remaining_gaps: payload.remaining_gaps || "Continue building skip-level visibility.", + recommendation: payload.recommendation || "ready", + }, + assessments: (detail.assessments || []).map((a) => ({ + roleExpectationId: a.roleExpectationId, + mark: a.mark, + })), + }), + }); + return { ...draft, status: "submitted" }; } async function shot(page, name) { @@ -39,15 +73,30 @@ async function shot(page, name) { console.log("wrote", file); } +async function gotoApp(page, route) { + await page.goto(`${UI}${route}`, { waitUntil: "networkidle0" }); + await page.waitForSelector(".main, .app-shell", { timeout: 15000 }); + await new Promise((r) => setTimeout(r, 450)); +} + +async function scrollHeadingIntoView(page, text) { + await page.evaluate((heading) => { + const nodes = Array.from(document.querySelectorAll("h1, h2, h3, .page-header, .panel")); + const el = nodes.find((n) => (n.textContent || "").includes(heading)); + el?.scrollIntoView({ block: "start" }); + }, text); + await new Promise((r) => setTimeout(r, 350)); +} + async function main() { const token = await unlockToken(); const people = await apiJson(token, "/api/people"); const cycles = await apiJson(token, "/api/cycles"); const promos = await apiJson(token, "/api/promotions"); + const promo = await ensureSubmittedPromo(token, promos); const directs = people.filter((p) => p.managerId); const person = directs[0] || people[0]; const cycle = cycles[0]; - const promo = promos[0]; const browser = await puppeteer.launch({ executablePath: process.env.CHROME_PATH || "/usr/bin/google-chrome-stable", @@ -77,8 +126,10 @@ async function main() { await page.evaluate((t) => localStorage.setItem("prm_token", t), token2); await page.goto(UI, { waitUntil: "networkidle0" }); await page.waitForSelector(".app-shell", { timeout: 15000 }); - await new Promise((r) => setTimeout(r, 500)); + await new Promise((r) => setTimeout(r, 700)); await shot(page, "02-home"); + await scrollHeadingIntoView(page, "Needs director attention"); + await shot(page, "15-director-attention"); const routes = [ ["03-team", "/team"], @@ -96,8 +147,7 @@ async function main() { if (promo?.id) routes.splice(6, 0, ["09-promotion-detail", `/promotions/${promo.id}`]); for (const [name, route] of routes) { - await page.goto(`${UI}${route}`, { waitUntil: "networkidle0" }); - await page.waitForSelector(".main, .app-shell", { timeout: 15000 }); + await gotoApp(page, route); if (route === "/chat") { await page.waitForSelector(".chat-composer button", { timeout: 15000 }); await page.evaluate(() => { @@ -107,8 +157,43 @@ async function main() { if (route === "/team-performance") { await page.waitForSelector(".tp-metrics, .tp-grid, h2", { timeout: 15000 }); } + if (name === "07-writing-desk") { + await page.waitForFunction( + () => (document.body?.innerText || "").includes("Evidence digest"), + { timeout: 15000 }, + ); + await scrollHeadingIntoView(page, "Evidence digest"); + } + if (name === "10-consistency") { + await page.waitForFunction( + () => Array.from(document.querySelectorAll("h2")).some((h) => h.textContent?.includes("Calibration bundles")), + { timeout: 15000 }, + ); + await scrollHeadingIntoView(page, "Calibration bundles"); + } + if (name === "09-promotion-detail") { + await page.waitForFunction( + () => (document.body?.innerText || "").includes("Promo committee export"), + { timeout: 15000 }, + ); + } await new Promise((r) => setTimeout(r, 450)); await shot(page, name); + + if (name === "07-writing-desk") { + await scrollHeadingIntoView(page, "Evidence digest"); + await shot(page, "16-writing-desk-director"); + } + if (name === "10-consistency") { + await scrollHeadingIntoView(page, "Calibration bundles"); + await shot(page, "17-consistency-director-packs"); + } + if (name === "11-team-performance") { + await shot(page, "18-team-director-briefing"); + } + if (name === "09-promotion-detail") { + await shot(page, "19-promo-committee"); + } } await browser.close();