Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,43 @@ jobs:
projectPath: apps/desktop
args: ${{ matrix.args }}
includeUpdaterJson: false
- name: Smoke API sidecar
shell: bash
run: |
set -euo pipefail
SIDE="apps/desktop/src-tauri/sidecars"
UI="apps/desktop/src-tauri/ui-dist"
if [[ "${{ runner.os }}" == "Windows" ]]; then
NODE="$SIDE/node.exe"
else
NODE="$SIDE/node"
chmod +x "$NODE" || true
fi
test -f "$NODE"
test -f "$SIDE/prm-api.mjs"
test -f "$UI/index.html"
DATA="${RUNNER_TEMP:-/tmp}/prm-sidecar-smoke"
rm -rf "$DATA"
mkdir -p "$DATA"
PRM_DATA_DIR="$DATA" PRM_PORT=8799 PRM_LAUNCH_TOKEN=ci-smoke PRM_UI_DIR="$UI" \
"$NODE" "$SIDE/prm-api.mjs" >"$DATA/out.log" 2>&1 &
PID=$!
cleanup() { kill "$PID" 2>/dev/null || true; }
trap cleanup EXIT
for i in $(seq 1 50); do
if curl -sf "http://127.0.0.1:8799/api/health" >/dev/null; then
echo "sidecar healthy"
curl -sf "http://127.0.0.1:8799/api/workspace/status" -H "x-prm-launch-token: ci-smoke"
echo
curl -sf "http://127.0.0.1:8799/" | head -c 200
echo
exit 0
fi
sleep 0.2
done
echo "sidecar failed to become healthy" >&2
cat "$DATA/out.log" >&2 || true
exit 1
- name: Upload desktop installers
uses: actions/upload-artifact@v4
with:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ apps/ui/dist
apps/api/dist
apps/desktop/src-tauri/target
apps/desktop/src-tauri/sidecars/
apps/desktop/src-tauri/ui-dist/
*.tsbuildinfo
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ pnpm desktop:build

Packaged builds on `main` CI upload installers as Actions artifacts (`desktop-windows-x86_64`, `desktop-macos-aarch64`, `desktop-linux-x86_64`). Details: [`apps/desktop/README.md`](./apps/desktop/README.md).

The desktop shell spawns a bundled Node API sidecar, injects `window.__PRM_API_BASE_URL__` + launch token into the webview, and stores vault data under the OS app-data directory (not a CI build path). If unlock hangs, check `desktop-api.log` next to the vault.
The desktop shell spawns a bundled Node API sidecar, serves the UI from that same origin (avoids WebView `Failed to fetch`), injects a launch token into the webview, and stores vault data under the OS app-data directory (not a CI build path). If unlock hangs, check `desktop-api.log` next to the vault.

### Cursor MCP (bulk load)

Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export type AiFeature =
| "chat_dossier"
| "bias_lint"
| "peer_synthesize"
| "framework_extract";
| "framework_extract"
| "meeting_summary";

export type ChatMessage = { role: "system" | "user" | "assistant"; content: string };

Expand Down
37 changes: 37 additions & 0 deletions apps/api/src/faithfulness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ const GOLDEN = {
allowlist: ["ach_1"],
expectInvention: false,
},
thinEvidenceHonesty: {
text: "Evidence is thin this cycle, so this assessment is provisional pending more dossier capture.",
allowlist: [] as string[],
expectInvention: false,
},
multiInventedCite: {
text: "Claim A [ach_x] and claim B [ach_y] without allowlist support.",
allowlist: ["ach_real"],
expectIds: ["unknown_cite_ach_x", "unknown_cite_ach_y"],
},
absolutePlusMetric: {
text: "They always delivered 100% of roadmap commits with zero misses across the org.",
allowlist: ["ach_1"],
expectMetricOrAbsolute: true,
},
};

describe("faithfulness lint", () => {
Expand Down Expand Up @@ -105,6 +120,8 @@ describe("faithfulness golden eval set", () => {
GOLDEN.inventedCite,
GOLDEN.ungroundedAbsolutes,
GOLDEN.bareMetric,
GOLDEN.multiInventedCite,
GOLDEN.absolutePlusMetric,
];
for (const c of cases) {
const findings = runFaithfulnessLint(c.text, c.allowlist);
Expand All @@ -114,4 +131,24 @@ describe("faithfulness golden eval set", () => {
);
}
});

it("does not punish thin-evidence honesty", () => {
const findings = runFaithfulnessLint(
GOLDEN.thinEvidenceHonesty.text,
GOLDEN.thinEvidenceHonesty.allowlist,
);
assert.equal(
findings.filter((f) => f.severity === "warn" && f.category === "invention").length,
0,
);
});

it("flags multiple invented cites", () => {
const findings = runFaithfulnessLint(
GOLDEN.multiInventedCite.text,
GOLDEN.multiInventedCite.allowlist,
);
assert.ok(findings.some((f) => f.id === "unknown_cite_ach_x"));
assert.ok(findings.some((f) => f.id === "unknown_cite_ach_y"));
});
});
150 changes: 137 additions & 13 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { basename } from "node:path";
import { access, readFile } from "node:fs/promises";
import { basename, extname, join, normalize, resolve, sep } from "node:path";
import { and, eq, isNull } from "drizzle-orm";
import {
achievements,
Expand All @@ -26,6 +27,7 @@ import {
roleFrameworks,
workspace,
concernLogs,
meetingSummaries,
integrationSettings,
personExternalLinks,
} from "@prm/db";
Expand Down Expand Up @@ -59,6 +61,7 @@ import {
encryptSecret,
encryptionStatus,
getDb,
getSqlite,
getSessionToken,
getWorkspaceSecret,
id,
Expand Down Expand Up @@ -91,6 +94,8 @@ import {
import { extractCitationIds, filterCitationsAgainstAllowlist } from "./citations.js";
import { runBiasToneLint } from "./bias.js";
import { mergeLintFindings, runFaithfulnessLint } from "./faithfulness.js";
import { registerP1Routes } from "./p1Routes.js";
import { indexReviewFts } from "./reviewSearch.js";
import { renderPromotionPacketHtml, renderSubjectPacketHtml } from "./packetHtml.js";
import { localPeerSynthesize } from "./peerSynth.js";
import { bitbucketSearchMembers, bitbucketTestConnection } from "./bitbucket.js";
Expand Down Expand Up @@ -137,12 +142,22 @@ const CORS_ORIGINS = [
"tauri://localhost",
"https://tauri.localhost",
"http://tauri.localhost",
"https://asset.localhost",
"http://asset.localhost",
];

function corsOrigin(origin: string): string | null {
if (CORS_ORIGINS.includes(origin)) return origin;
// Windows/Android custom-protocol workaround hosts: http(s)://*.localhost
if (/^https?:\/\/(?:[a-z0-9-]+\.)*localhost(?::\d+)?$/i.test(origin)) return origin;
if (origin.startsWith("tauri://")) return origin;
return null;
}

app.use(
"*",
cors({
origin: CORS_ORIGINS,
origin: (origin) => (origin ? corsOrigin(origin) : CORS_ORIGINS[0]),
credentials: true,
allowHeaders: ["Content-Type", "x-prm-token", "x-prm-launch-token"],
}),
Expand Down Expand Up @@ -506,7 +521,7 @@ app.get("/api/roles", (c) => {

app.get("/api/roles/framework", (c) => {
const info = frameworkInfo();
return c.json(info ?? { frameworkId: null, frameworkName: null, versionId: null, versionLabel: null, sourceFilename: null, roleCount: 0 });
return c.json(info ?? { frameworkId: null, frameworkName: null, versionId: null, versionLabel: null, sourceFilename: null, roleCount: 0, ownerName: null, effectiveFrom: null, effectiveTo: null, insufficient: [] });
});

function getPublishedVersion() {
Expand Down Expand Up @@ -536,6 +551,25 @@ function ensurePublishedVersion() {
return db.select().from(roleFrameworkVersions).where(eq(roleFrameworkVersions.id, fwvId)).all()[0]!;
}

function frameworkInsufficient(versionId: string): string[] {
const db = getDb();
const warnings: string[] = [];
const roles = db.select().from(roleDefinitions).where(eq(roleDefinitions.frameworkVersionId, versionId)).all();
if (roles.length === 0) warnings.push("No role definitions in the published version.");
const keys = new Set(roles.map((r) => r.key));
for (const role of roles) {
const exps = db.select().from(roleExpectations).where(eq(roleExpectations.roleDefinitionId, role.id)).all();
if (exps.length === 0) warnings.push(`Role “${role.title}” has no expectations.`);
for (const next of JSON.parse(role.nextRoleKeysJson) as string[]) {
if (!keys.has(next)) warnings.push(`Role “${role.title}” points to missing next key “${next}”.`);
}
}
const directs = db.select().from(people).all().filter((p) => p.managerId && p.status === "active");
const unassigned = directs.filter((p) => !assignmentFor(p.id));
if (unassigned.length) warnings.push(`${unassigned.length} active direct(s) have no role assignment.`);
return warnings;
}

function frameworkInfo(): RoleFrameworkInfo | null {
const db = getDb();
const version = getPublishedVersion();
Expand All @@ -553,6 +587,10 @@ function frameworkInfo(): RoleFrameworkInfo | null {
versionLabel: version.versionLabel,
sourceFilename: version.sourceFilename,
roleCount,
ownerName: version.ownerName ?? null,
effectiveFrom: version.effectiveFrom ?? null,
effectiveTo: version.effectiveTo ?? null,
insufficient: frameworkInsufficient(version.id),
};
}

Expand Down Expand Up @@ -1740,6 +1778,19 @@ app.put("/api/reviews/manager", async (c) => {
evidenceLinks: links.length,
timelineEntries: frozenTimeline.length,
});

try {
const person = db.select().from(people).where(eq(people.id, body.subjectPersonId)).all()[0];
const cycle = db.select().from(cycles).where(eq(cycles.id, body.cycleId)).all()[0];
indexReviewFts(getSqlite(), {
reviewId,
personName: person?.name ?? "",
cycleName: cycle?.name ?? "",
body: narrative,
});
} catch {
/* FTS optional */
}
}

if (ratingChanges.length && reviewId) {
Expand Down Expand Up @@ -3249,8 +3300,8 @@ app.post("/api/ai/bias-lint", async (c) => {
}>();
let text = body.text?.trim() ?? "";
let allowlist: string[] = [];
if (!text && body.cycleId && body.personId) {
const db = getDb();
const db = getDb();
if (body.cycleId && body.personId) {
const manager = db
.select()
.from(reviews)
Expand All @@ -3263,28 +3314,32 @@ app.post("/api/ai/bias-lint", async (c) => {
)
.all()
.find((r) => r.status !== "superseded");
if (manager) {
if (!text && manager) {
const p = JSON.parse(manager.payloadJson) as Record<string, unknown>;
text = [p.summary, p.strengths, p.growth_areas, p.dev_plan, p.role_fit].map((x) => String(x ?? "")).join("\n\n");
}
if (manager) {
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),
);
}
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),
...db.select().from(meetingSummaries).where(eq(meetingSummaries.personId, body.personId)).all().map((m) => m.id),
);
allowlist = [...new Set(allowlist)];
}
const findings = mergeLintFindings(
runBiasToneLint(text),
runFaithfulnessLint(text, allowlist),
);
return c.json({ findings, ephemeral: true });
return c.json({ findings, ephemeral: true, allowlistSize: allowlist.length });
});

app.get("/api/sprint-pulse", (c) => {
Expand Down Expand Up @@ -3823,6 +3878,75 @@ app.get("/api/exports/promotion/:id", (c) => {
});
});

registerP1Routes(app, {
loadRole,
assignmentFor,
getPublishedVersion: () => {
const v = getPublishedVersion();
if (!v) return null;
return {
id: v.id,
frameworkId: v.frameworkId,
versionLabel: v.versionLabel,
sourceFilename: v.sourceFilename,
ownerName: v.ownerName ?? null,
effectiveFrom: v.effectiveFrom ?? null,
effectiveTo: v.effectiveTo ?? null,
};
},
frameworkInsufficient,
});

/** Packaged desktop: serve the Vite UI from PRM_UI_DIR so the webview is same-origin with /api. */
const uiDir = process.env.PRM_UI_DIR?.trim();
if (uiDir) {
const root = resolve(uiDir);
const mime: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".json": "application/json",
".map": "application/json",
};
const inside = (file: string) => {
const f = resolve(file);
return f === root || f.startsWith(root + sep);
};
app.get("*", async (c) => {
const urlPath = decodeURIComponent(new URL(c.req.url).pathname);
if (urlPath.startsWith("/api")) return c.json({ error: "Not found" }, 404);
const rel =
!urlPath || urlPath === "/"
? "index.html"
: normalize(urlPath.replace(/^[/\\]+/, "")).replace(/^(\.\.([/\\]|$))+/g, "");
const candidates = [join(root, rel)];
if (!extname(rel)) candidates.push(join(root, rel, "index.html"));
candidates.push(join(root, "index.html"));
for (const file of candidates) {
if (!inside(file)) continue;
try {
await access(file);
const body = await readFile(file);
const type = mime[extname(file).toLowerCase()] ?? "application/octet-stream";
return new Response(body, { headers: { "content-type": type } });
} catch {
/* try next */
}
}
return c.text("Not found", 404);
});
console.log(`PRM UI static root: ${root}`);
}

const port = Number(process.env.PRM_PORT ?? 8787);
console.log(`PRM API listening on http://127.0.0.1:${port}`);
serve({ fetch: app.fetch, port, hostname: "127.0.0.1" });
Loading
Loading