Skip to content

Commit e17b4ad

Browse files
some-random-agentclaude
andcommitted
v0.23.2 — wire Phase 1 endpoints into the dashboard UI (the missing user-facing half)
v0.23.0 shipped the lint / polisher / security-scan / exemplar-tagging API endpoints but FORGOT to add buttons to the dashboard. The features were unreachable through the UI — only direct curl/SQL access worked. This patch closes the gap. Per skill row in the Active Skills panel: ✨ Polish → POST /dashboard/skills/:id/polish/html Side-by-side original-vs-polished diff with lint badge, Apply button (disabled when polished == original or lint fails) Recent runs → GET /dashboard/skills/:id/runs Last 50 skill_runs with status / score / agent / run_id; each row has ☆ Tag exemplar / ★ Exemplar disabled 🛡 Security → GET /dashboard/skills/:id/security Last 30 security scan rows with verdict, source attribution, collapsible per-failure detail with severity color-coding ☆/★ button → POST /dashboard/skill-runs/:run_id/tag-exemplar/html Operator clicks ☆, browser prompt asks for note, server stores tag + note + timestamp, swaps the row inline Fixes: - 30s skills auto-poll now pauses while ANY .skill-edit-zone is non-empty so the polish/runs/security expansion isn't wiped out mid-interaction - HTMX URL-encodes the HX-Prompt header (HTTP can't carry raw non-ASCII). Server now decodeURIComponent's it before storing — without this fix the operator's note lands in PG as "Hello%20%E2%80%94" instead of "Hello —". Caught DURING browser click-through (PG row inspection). Verification — all surfaces clicked through Playwright with real telemetry on Test_Agent_Coordination's developer-debugging-methodology@1.1@global: - 10 real skill_runs render in the runs panel - ☆ click → button swap to ★ Exemplar [disabled] → PG row updated with decoded note, tagged_by=operator, tagged_at=NOW() - ✨ click → polish preview renders with side-by-side diff, lint badge, Apply button correctly disabled for already-good descriptions - 🛡 click → 2 prior scan rows render with 8/8 PASS verdict and operator source attribution from the v0.23.0 API E2E The browser pass also exposed the URL-encode bug. That's the value of real user-level testing over backend-only verification — bugs surface where the user actually clicks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bc56322 commit e17b4ad

6 files changed

Lines changed: 516 additions & 10 deletions

File tree

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "zc-ctx",
3-
"version": "0.23.1",
3+
"version": "0.23.2",
44
"description": "Secure memory & context optimization MCP plugin for Claude Code — drop-in replacement for context-mode with credential isolation, SSRF protection, MemGPT-style persistent memory, and A2A multi-agent broadcast channel. 25+ MCP tools, HMAC-chained tool_calls + outcomes, per-agent HKDF subkey isolation, Postgres RLS, work-stealing queue, self-improving skills, and Agent Notebook Model (Read→Summary redirect). MIT license.",
55
"type": "mcp",
66
"mcp": {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "zc-ctx",
3-
"version": "0.23.1",
3+
"version": "0.23.2",
44
"description": "Secure memory & context optimization MCP plugin for Claude Code — drop-in replacement for context-mode with credential isolation, SSRF protection, MemGPT-style persistent memory, and A2A multi-agent broadcast channel",
55
"keywords": [
66
"claude-code",

scripts/_e2e_real_exemplar.mjs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// v0.23.0 Phase 1 F — verify REAL exemplar (run-8c34b318-f98) flows into proposer prompt
2+
import { getExemplarRuns } from "/app/dist/skills/storage_dual.js";
3+
import { buildProposerPrompt } from "/app/dist/skills/mutator.js";
4+
import { getActiveSkill } from "/app/dist/skills/storage_dual.js";
5+
import { DatabaseSync } from "node:sqlite";
6+
7+
const skillId = "developer-debugging-methodology";
8+
const scope = "global";
9+
10+
console.log("=== Step 1: getExemplarRuns for the live skill ===");
11+
// First get the active version's full id
12+
const db = new DatabaseSync(":memory:");
13+
const exemplars = await getExemplarRuns("developer-debugging-methodology@1.1@global", 5);
14+
console.log(`Found ${exemplars.length} exemplar(s) for developer-debugging-methodology@1.1@global`);
15+
for (const e of exemplars) {
16+
console.log(` - run_id=${e.run_id} note="${e.note}"`);
17+
console.log(` inputs=${JSON.stringify(e.inputs).slice(0, 100)}...`);
18+
}
19+
20+
console.log("\n=== Step 2: Build a proposer prompt using the live skill as parent ===");
21+
const parent = await getActiveSkill(db, "developer-debugging-methodology", "global");
22+
if (!parent) {
23+
console.log("FAIL: parent skill not found via getActiveSkill");
24+
process.exit(1);
25+
}
26+
console.log(`Parent: ${parent.skill_id}, body length: ${parent.body.length}`);
27+
28+
const prompt = buildProposerPrompt({
29+
parent,
30+
recent_runs: [],
31+
failure_traces: ["example failure trace 1"],
32+
fixtures: parent.frontmatter.fixtures ?? [],
33+
exemplars,
34+
});
35+
36+
const idx = prompt.indexOf("## Operator-tagged exemplars");
37+
if (idx === -1) {
38+
console.log("FAIL: prompt missing exemplar section");
39+
process.exit(1);
40+
}
41+
const end = prompt.indexOf("## ", idx + 30);
42+
const section = prompt.slice(idx, end !== -1 ? end : idx + 800);
43+
console.log("\nExemplar section in prompt:");
44+
console.log("─".repeat(60));
45+
console.log(section);
46+
console.log("─".repeat(60));
47+
48+
console.log(`\nResult: PASS — real exemplar (${exemplars[0]?.run_id ?? "?"}) flows into proposer prompt`);
49+
console.log(`Total prompt length: ${prompt.length} chars`);

src/api-server.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,190 @@ export async function createApiServer(storeOverride?: Store) {
975975
}
976976
});
977977

978+
// v0.23.2 — HTML variant for the dashboard ⭐ button. Same DB write, but
979+
// returns a single rendered <tr> so HTMX can swap it inline in the runs
980+
// table. The note comes from HTMX's hx-prompt header (operator's typed
981+
// input on click).
982+
app.post("/dashboard/skill-runs/:run_id/tag-exemplar/html", async (request, reply) => {
983+
const params = request.params as Record<string, string>;
984+
const runId = String(params.run_id ?? "").trim();
985+
// HTMX URL-encodes the HX-Prompt header value because HTTP headers can't
986+
// carry raw non-ASCII bytes (em dashes, emoji, multi-byte UTF-8). Without
987+
// decodeURIComponent here the operator's note lands in PG as
988+
// "Hello%20world%20%E2%80%94" instead of "Hello world —". Caught in
989+
// browser E2E click-through.
990+
const headers = request.headers as Record<string, string | string[] | undefined>;
991+
const promptRaw = headers["hx-prompt"];
992+
let note: string | null = null;
993+
if (typeof promptRaw === "string" && promptRaw.length > 0) {
994+
try { note = decodeURIComponent(promptRaw).slice(0, 1024); }
995+
catch { note = promptRaw.slice(0, 1024); } // graceful fallback if not valid percent-encoding
996+
}
997+
if (!runId) {
998+
reply.status(400).type("text/html").send(`<tr><td colspan="6" class="error">missing run_id</td></tr>`);
999+
return;
1000+
}
1001+
try {
1002+
const { withClient } = await import("./pg_pool.js");
1003+
const updated = await withClient(async (c) => {
1004+
const r = await c.query(
1005+
`UPDATE skill_runs_pg
1006+
SET is_exemplar = TRUE,
1007+
exemplar_tagged_by = $1,
1008+
exemplar_tagged_at = NOW(),
1009+
exemplar_note = $2
1010+
WHERE run_id = $3
1011+
RETURNING run_id, skill_id, status, outcome_score, ts, agent_id,
1012+
is_exemplar, exemplar_note`,
1013+
["operator", note, runId],
1014+
);
1015+
return r.rows[0] ?? null;
1016+
});
1017+
if (!updated) {
1018+
reply.status(404).type("text/html").send(`<tr><td colspan="6" class="error">run ${escapeHtml(runId)} not found</td></tr>`);
1019+
return;
1020+
}
1021+
const { renderSkillRunRow } = await import("./dashboard/render.js");
1022+
const html = renderSkillRunRow({
1023+
run_id: updated.run_id,
1024+
skill_id: updated.skill_id,
1025+
status: updated.status,
1026+
outcome_score: updated.outcome_score === null ? null : Number(updated.outcome_score),
1027+
ts: updated.ts instanceof Date ? updated.ts.toISOString() : String(updated.ts),
1028+
agent_id: updated.agent_id,
1029+
is_exemplar: Boolean(updated.is_exemplar),
1030+
exemplar_note: updated.exemplar_note,
1031+
});
1032+
reply.type("text/html").send(html);
1033+
} catch (e) {
1034+
reply.status(500).type("text/html").send(`<tr><td colspan="6" class="error">error: ${escapeHtml((e as Error).message)}</td></tr>`);
1035+
}
1036+
});
1037+
1038+
// v0.23.2 — Recent skill_runs for a skill (HTML for HTMX)
1039+
app.get("/dashboard/skills/:id/runs", async (request, reply) => {
1040+
const params = request.params as Record<string, string>;
1041+
const skillId = String(params.id ?? "").trim();
1042+
if (!skillId) {
1043+
reply.status(400).type("text/html").send(`<div class="error">missing skill_id</div>`);
1044+
return;
1045+
}
1046+
try {
1047+
const { withClient } = await import("./pg_pool.js");
1048+
const rows = await withClient(async (c) => {
1049+
const r = await c.query(
1050+
`SELECT run_id, skill_id, status, outcome_score, ts, agent_id,
1051+
is_exemplar, exemplar_note
1052+
FROM skill_runs_pg
1053+
WHERE skill_id = $1
1054+
ORDER BY ts DESC
1055+
LIMIT 50`,
1056+
[skillId],
1057+
);
1058+
return r.rows;
1059+
});
1060+
const { renderSkillRunsFragment } = await import("./dashboard/render.js");
1061+
const html = renderSkillRunsFragment(skillId, rows.map((r: Record<string, unknown>) => ({
1062+
run_id: String(r.run_id),
1063+
skill_id: String(r.skill_id),
1064+
status: String(r.status),
1065+
outcome_score: r.outcome_score === null ? null : Number(r.outcome_score),
1066+
ts: r.ts instanceof Date ? r.ts.toISOString() : String(r.ts),
1067+
agent_id: r.agent_id === null ? null : String(r.agent_id),
1068+
is_exemplar: Boolean(r.is_exemplar),
1069+
exemplar_note: r.exemplar_note === null ? null : String(r.exemplar_note),
1070+
})));
1071+
reply.type("text/html").send(html);
1072+
} catch (e) {
1073+
reply.status(500).type("text/html").send(`<div class="error">error: ${escapeHtml((e as Error).message)}</div>`);
1074+
}
1075+
});
1076+
1077+
// v0.23.2 — Security scan history for a skill (HTML for HTMX)
1078+
app.get("/dashboard/skills/:id/security", async (request, reply) => {
1079+
const params = request.params as Record<string, string>;
1080+
const skillId = String(params.id ?? "").trim();
1081+
if (!skillId) {
1082+
reply.status(400).type("text/html").send(`<div class="error">missing skill_id</div>`);
1083+
return;
1084+
}
1085+
try {
1086+
const { withClient } = await import("./pg_pool.js");
1087+
const rows = await withClient(async (c) => {
1088+
const r = await c.query(
1089+
`SELECT scanned_at, score, passed, source, failures
1090+
FROM skill_security_scans_pg
1091+
WHERE skill_id = $1
1092+
ORDER BY scanned_at DESC
1093+
LIMIT 30`,
1094+
[skillId],
1095+
);
1096+
return r.rows;
1097+
});
1098+
const { renderSecurityScansFragment } = await import("./dashboard/render.js");
1099+
const html = renderSecurityScansFragment(skillId, rows.map((r: Record<string, unknown>) => ({
1100+
scanned_at: r.scanned_at instanceof Date ? r.scanned_at.toISOString() : String(r.scanned_at),
1101+
score: Number(r.score),
1102+
passed: Boolean(r.passed),
1103+
source: String(r.source),
1104+
failures: typeof r.failures === "string" ? JSON.parse(r.failures) : (r.failures as Array<{ name: string; severity: string; detail: string | null }>),
1105+
})));
1106+
reply.type("text/html").send(html);
1107+
} catch (e) {
1108+
reply.status(500).type("text/html").send(`<div class="error">error: ${escapeHtml((e as Error).message)}</div>`);
1109+
}
1110+
});
1111+
1112+
// v0.23.2 — HTML wrapper around the JSON polish endpoint. Same logic, but
1113+
// returns a rendered preview <div> with the diff + Apply button.
1114+
app.post("/dashboard/skills/:id/polish/html", async (request, reply) => {
1115+
const params = request.params as Record<string, string>;
1116+
const skillId = String(params.id ?? "").trim();
1117+
if (!skillId) {
1118+
reply.status(400).type("text/html").send(`<div class="error">missing skill_id</div>`);
1119+
return;
1120+
}
1121+
try {
1122+
const { withClient } = await import("./pg_pool.js");
1123+
const skill = await withClient(async (c) => {
1124+
const r = await c.query("SELECT skill_id, frontmatter, body, body_hmac FROM skills_pg WHERE skill_id = $1 AND archived_at IS NULL", [skillId]);
1125+
if (r.rows.length === 0) return null;
1126+
const row = r.rows[0];
1127+
return {
1128+
skill_id: row.skill_id,
1129+
frontmatter: typeof row.frontmatter === "string" ? JSON.parse(row.frontmatter) : row.frontmatter,
1130+
body: row.body,
1131+
body_hmac: row.body_hmac,
1132+
source_path: null,
1133+
promoted_from: null,
1134+
created_at: new Date().toISOString(),
1135+
archived_at: null,
1136+
archive_reason: null,
1137+
};
1138+
});
1139+
if (!skill) {
1140+
reply.status(404).type("text/html").send(`<div class="error">skill ${escapeHtml(skillId)} not found or archived</div>`);
1141+
return;
1142+
}
1143+
const { polishSkillDescription } = await import("./skills/polisher.js");
1144+
const result = await polishSkillDescription(skill);
1145+
const { renderPolishPreview } = await import("./dashboard/render.js");
1146+
const html = renderPolishPreview({
1147+
skill_id: skill.skill_id,
1148+
original: result.original,
1149+
polished: result.polished,
1150+
lint_passed: result.lint_passed,
1151+
lint_warnings: result.lint_warnings,
1152+
lint_errors: result.lint_errors,
1153+
backend: result.backend,
1154+
duration_ms: result.duration_ms,
1155+
});
1156+
reply.type("text/html").send(html);
1157+
} catch (e) {
1158+
reply.status(500).type("text/html").send(`<div class="error">polish error: ${escapeHtml((e as Error).message)}</div>`);
1159+
}
1160+
});
1161+
9781162
// v0.23.0 Phase 1 #2 — Polish a skill's description.
9791163
// Operator-triggered via dashboard button. Returns the suggested polish
9801164
// (does NOT auto-apply); operator reviews and POSTs to /apply if approved.

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const env = process.env;
3737

3838
export const Config = {
3939
// ── Version ──────────────────────────────────────────────────────────────
40-
VERSION: "0.23.1",
40+
VERSION: "0.23.2",
4141

4242
// ── Storage paths ────────────────────────────────────────────────────────
4343
DB_DIR: join(homedir(), ".claude", "zc-ctx", "sessions"),

0 commit comments

Comments
 (0)