Commit 1f7cec119c702e32c0083fc8acfebfd09525e49d

Authored by bboonnee
1 parent accfe51f

U-029: v_skill_library + v_skill_audit_log

c_skill_diff 和 U-028 的 c_artifact_get 一样,只存在于机器可读的
sdd-mvcp-contract.json,03-C 正文从未写出来,已订正补全。
c_skill_list/c_skill_get 两份文档都没定义,是 v_skill_library/
v_skill_audit_log 自己的展示需求倒逼出来的缺口。

新增 src/skill_query.py:list_skills()、get_skill_audit_log()、
get_diff()(git diff <before> <after>——skill_update.py 的
_write_and_commit 把每次迭代做成原子单文件 commit,整仓库范围的 diff
天然只涉及那一个 Skill 文件)。

关键安全考量:Skill 库活在 graphlemon 自己的仓库里,c_skill_rollback
会真的在这个仓库里跑 git commit——如果 HTTP 层写死指向真实仓库根目
录,任何针对这条路由的测试都会在正在跑的这个 graphlemon 仓库里留下
真实提交。参照 db_path 已有的做法,给 create_server() 加了
skill_repo_path 参数(默认 graphlemon 真实仓库根,测试传临时仓库),
不通过 HTTP 暴露这个参数,避免变成任意写入的口子。记录在 05
§5.4.1.4。

真实服务器 + 临时仓库端到端验证过完整的 update→diff→rollback 闭环,
确认过跑完之后 graphlemon 自己仓库的 git log 完全没变化。

v_skill_library/v_skill_audit_log 不检查"当前项目"——Skill 是平台级
资产,跟项目无关。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
... ... @@ -267,6 +267,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
267 267  
268 268 - **U-029 [Value Path]** `v_skill_library` + `v_skill_audit_log`
269 269 - 依赖:U-018
  270 + - Done:Skill 库与变更审计日志可查看(原文档缺 Done/覆盖行,2026-07-31 补上)
  271 + - **实现补充(2026-07-31)**:`c_skill_diff` 和 U-028 的 `c_artifact_get` 一样,只存在于机器可读的 `sdd-mvcp-contract.json`(`GET /api/skills/diff?before=&after=`),03-C 正文从未写出来,本 Unit 订正补全;list/get 系读接口(`c_skill_list`/`c_skill_get`)两份文档都没定义,跟 `c_task_list`/`c_artifact_list` 是同一类"视图倒逼出来的缺口"。新增 `src/skill_query.py`:`list_skills()`、`get_skill_audit_log()`、`get_diff()`(`git diff <before> <after>`——`skill_update.py` 的 `_write_and_commit` 把每次迭代做成原子的单文件 commit,所以整仓库范围的 diff 天然就只涉及这一个 Skill 文件,不需要额外传 path 过滤)。**关键安全考量**:Skill 库活在 graphlemon 自己的仓库里(不是某个 target project 的),`c_skill_rollback` 会真的在这个仓库里跑 `git commit`——如果 HTTP 层写死指向真实仓库根目录,任何针对这条路由的测试都会在正在跑的这个 graphlemon 仓库里留下真实提交。参照 `db_path` 已有的做法,给 `create_server()` 加了 `skill_repo_path` 参数(默认 `skill_query._REPO_ROOT`,即 graphlemon 真实仓库根,生产环境下这就是对的行为;测试传一个 tempfile 建的临时仓库,绝不碰真实历史),不通过 HTTP 暴露这个参数(只在进程/测试构造时可设,避免变成一个可以让客户端指定任意写入目标的口子)。真实服务器 + 临时仓库端到端验证过完整的 update→diff→rollback 闭环,并且确认过跑完之后 graphlemon 自己仓库的 git log 完全没变化。前端 `v_skill_library`/`v_skill_audit_log` 两个页面不检查"当前项目"(Skill 是平台级资产,跟项目无关,是这个控制台里唯一不需要 `getCurrentProjectId()` 守卫的真实页面)。
  272 + - 覆盖:02-V `v_skill_library`/`v_skill_audit_log`;03-C `c_skill_diff`(正文订正补全)+ 新增 `c_skill_list`/`c_skill_get`;`c_skill_rollback`(U-018 已实现 `rollback_skill()`)首次接上 HTTP
270 273  
271 274 ### PH3-S4 配置页
272 275  
... ...
docs/sdd/03-C-控制与接口.md
... ... @@ -196,6 +196,13 @@
196 196 - `idempotent`:否
197 197 - `errors`:`ITERATION_NOT_FOUND`(不可重试)
198 198  
  199 +## 3.9.1 `c_skill_diff` / `c_skill_list` / `c_skill_get`(2026-07-31 订正:`c_skill_diff` 此前只存在于机器可读的 `sdd-mvcp-contract.json`,正文一直没写;`c_skill_list`/`c_skill_get` 连 MVCP JSON 里都没有,`v_skill_library`/`v_skill_audit_log` 自己的展示需求倒逼补上)
  200 +
  201 +- `GET /api/skills/diff?before=&after=`(`c_skill_diff`)、`GET /api/skills`(`c_skill_list`)、`GET /api/skills/:skill_id/audit`(`c_skill_get` 的落地形态——直接返回该 Skill 的 `skill_iteration` 列表,供 `v_skill_audit_log` 消费,不是单条 `skill` 记录)
  202 +- `output_contract`:`c_skill_diff` 返回 `{ diff_text: string }`(`git diff <before> <after>`,在 graphlemon 自身仓库执行——见 05 §5.4.1.4);`c_skill_list` 返回裸数组;`c_skill_get` 返回 `skill_iteration` 数组,按 `applied_at` 升序
  203 +- `idempotent`:是
  204 +- `errors`:`NOT_FOUND`(不可重试,仅 `c_skill_get`):`skill_id` 不存在;`INVALID_INPUT`(不可重试,仅 `c_skill_diff`):`before`/`after` 缺失
  205 +
199 206 ## 3.10 `c_bug_record_create`
200 207  
201 208 - `POST /api/bug-records`
... ...
docs/sdd/05-实施约束与依赖.md
... ... @@ -54,6 +54,13 @@
54 54 - **应对方式**:`self.conn.close()` 也必须包在 `with self.conn_lock:` 里——"关闭"和"读写"对同一个连接对象而言都是需要互斥的操作,不能默认关闭是安全的。已在 U-027 修复,修复后连续跑几十次全部稳定通过。
55 55 - 这条不需要等 Claude Code 升级重新验证(跟它无关),但如果以后这里改用连接池/每线程一个连接,这条约束本身会自然消失,到时候可以直接删掉这一节。
56 56  
  57 +## 5.4.1.4 `c_skill_diff`/`c_skill_rollback` 的仓库路径不能通过 HTTP 暴露(设计约束,2026-07-31,U-029)
  58 +
  59 +- 背景:Skill 库是 graphlemon 自己平台的资产(不是 target project 的),`c_skill_rollback`(`src/skill_update.py` 的 `rollback_skill()`)会在这个仓库里真的跑 `git commit`;`repo_path` 参数 U-018 就有意不给默认值,防止哪个调用方忘了传就误伤 graphlemon 自己的历史(见 U-018 那次实现补充)。
  60 +- **风险点**:`backend_server.py` 把这个函数接到 `POST /api/skills/:skill_id/rollback` 时,如果 HTTP 层内部写死 `skill_repo_path=<graphlemon 真实仓库根>`,那么任何针对这条路由的自动化测试(`src/test_backend_server.py`)都会在正在跑的这个 graphlemon 仓库里留下真实 git 提交——测试本身就会污染开发者自己的仓库历史。
  61 +- **应对方式**:参照 `create_server()` 已有的 `db_path` 参数模式,加了 `skill_repo_path` 参数:默认值是 `skill_query._REPO_ROOT`(graphlemon 真实仓库根,生产环境下这就是唯一正确的行为——Skill 变更本来就应该落到这里),但可以在构造 server 时覆盖;测试统一传一个 `tempfile` 建的临时仓库。**这个参数只在 Python 层(进程启动/测试构造)可设,绝不通过 HTTP 请求体/query string 暴露给客户端**——否则等于开了一个"客户端指定任意目录、服务端替它跑 git commit"的口子,属于路径注入/任意写入范畴。
  62 +- 这不是"发现"的约束(不是实测跑出来的意外行为),是设计阶段就该考虑到、写代码时主动做的防护,记录下来是为了以后改这块代码的人不要图省事把 `skill_repo_path` 从请求里读出来。
  63 +
57 64 ## 5.4.2 Claude Code plan 额度等待重试参数(架构文档 7.5 节决策,2026-07-29)
58 65  
59 66 - 退避轮询间隔(5→10→20→30 分钟,上限 30 分钟)、超时兜底阈值(session 8 小时、weekly 9 天)是 MVP 阶段的硬编码默认值,不在管理后台开放用户配置,和 `log_retention_config` 的"保留天数可配、清理频率不可配"是同一个 MVP 简化原则
... ...
docs/sdd/unit_matrix.json
... ... @@ -44,7 +44,7 @@
44 44 {"id": "U-026", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_graph_visualizer(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_graph_visualizer"], "controller": [], "process": [], "requirement": []}, "done": "Work Graph DAG 分层视图含 waiting_for_quota 信息态;Org Graph 层复用 v_task_detail 已有的 org-strip 面板,不重复渲染"},
45 45 {"id": "U-027", "phase": "PH3", "sub_phase": "PH3-S3", "name": "execution_log_file + 日志查看器(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": ["execution_log_file", "log_retention_config"], "view": ["v_log_viewer"], "controller": ["c_execution_log_get", "c_ws_log_tail"], "process": ["p_log_retention_cleanup"], "requirement": ["REQ-13"]}, "done": "REQ-13 happy/boundary/failure 通过;补上此前从未存在的 append_log 写路径;顺带修了 U-024 就存在的 server_close() 并发 segfault 隐患(conn_lock 未覆盖)"},
46 46 {"id": "U-028", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_artifact_browser(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-011"], "coverage": {"model": [], "view": ["v_artifact_browser"], "controller": ["c_artifact_get", "c_artifact_list"], "process": [], "requirement": []}, "done": "Artifact 列表+预览+血缘链可用;预览为纯文本,不做 Markdown 渲染/代码高亮(避免引入前端第三方依赖)"},
47   - {"id": "U-029", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_skill_library + v_skill_audit_log", "classification": "Value Path", "depends_on": ["U-018"], "coverage": {"model": [], "view": ["v_skill_library", "v_skill_audit_log"], "controller": [], "process": [], "requirement": []}, "done": "Skill 库与变更审计日志可查看"},
  47 + {"id": "U-029", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_skill_library + v_skill_audit_log(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-018"], "coverage": {"model": [], "view": ["v_skill_library", "v_skill_audit_log"], "controller": ["c_skill_diff", "c_skill_list", "c_skill_get", "c_skill_rollback"], "process": [], "requirement": []}, "done": "Skill 库与变更审计日志可查看;c_skill_rollback 首次接上 HTTP(skill_repo_path 可注入,测试绝不碰真实仓库)"},
48 48 {"id": "U-030", "phase": "PH3", "sub_phase": "PH3-S4", "name": "并发/预算/日志保留配置页", "classification": "Value Path", "depends_on": ["U-019", "U-021"], "coverage": {"model": ["org_concurrency_config"], "view": ["v_concurrency_settings", "v_budget_settings", "v_log_retention_settings"], "controller": ["c_concurrency_config_update", "c_budget_config_update", "c_log_retention_config_update"], "process": [], "requirement": []}, "done": "三个配置页可用且改动生效"},
49 49  
50 50 {"id": "U-031", "phase": "PH4", "sub_phase": "PH4-S1", "name": "REQ-01~14 全量自动化回归", "classification": "Risk Gate", "depends_on": ["U-014", "U-017", "U-020", "U-021", "U-022", "U-023", "U-024.5", "U-025", "U-026", "U-027", "U-028", "U-029", "U-030"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-01", "REQ-02", "REQ-03", "REQ-04", "REQ-05", "REQ-06", "REQ-07", "REQ-08", "REQ-09", "REQ-10", "REQ-11", "REQ-12", "REQ-13", "REQ-14"]}, "done": "06 节全部 14 条 REQ 的全部用例通过一次完整回归"},
... ...
frontend/app.js
... ... @@ -78,15 +78,18 @@ const routes = {
78 78 "#/": { render: renderDashboard, mount: mountDashboard },
79 79 "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard },
80 80 "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser },
  81 + "#/skills": { render: renderSkillLibrary, mount: mountSkillLibrary },
81 82 "#/settings": { render: () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>" },
82 83 "#/setup": { render: renderProjectSetup, mount: mountProjectSetup },
83 84 "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer },
84 85 "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback },
85 86 };
86 87  
87   -// #/tasks/:task_id (v_task_detail) is the one dynamic route -- not worth a
88   -// full path-matching router for a single pattern.
  88 +// #/tasks/:task_id (v_task_detail) and #/skills/:skill_id/audit
  89 +// (v_skill_audit_log) are the only dynamic routes -- not worth a full
  90 +// path-matching router for two patterns.
89 91 const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/;
  92 +const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/;
90 93  
91 94 let currentRouteCleanup = null;
92 95 function registerRouteCleanup(fn) {
... ... @@ -103,10 +106,14 @@ function render() {
103 106 let route = routes[hash];
104 107 let routeArgs = [];
105 108 if (!route) {
106   - const match = hash.match(TASK_DETAIL_RE);
107   - if (match) {
  109 + const taskMatch = hash.match(TASK_DETAIL_RE);
  110 + const skillMatch = hash.match(SKILL_AUDIT_RE);
  111 + if (taskMatch) {
108 112 route = { render: renderTaskDetail, mount: mountTaskDetail };
109   - routeArgs = [match[1]];
  113 + routeArgs = [taskMatch[1]];
  114 + } else if (skillMatch) {
  115 + route = { render: renderSkillAuditLog, mount: mountSkillAuditLog };
  116 + routeArgs = [skillMatch[1]];
110 117 }
111 118 }
112 119 if (!route) route = { render: () => "<h1>Not found</h1><p>" + hash + "</p>" };
... ... @@ -1075,3 +1082,137 @@ function mountArtifactBrowser() {
1075 1082 });
1076 1083 registerRouteCleanup(() => socket.close());
1077 1084 }
  1085 +
  1086 +// ---- v_skill_library (02-V §2.9) + v_skill_audit_log (02-V §2.10) ----
  1087 +// Skills are a platform-wide asset living in graphlemon's own repo (see
  1088 +// skill_query.py), not scoped to any target project -- these two pages
  1089 +// deliberately don't check getCurrentProjectId(), unlike every other real
  1090 +// page in this console.
  1091 +
  1092 +const SOURCE_TAG_LABELS = {
  1093 + migrated_from_hermes: "migrated_from_hermes", new: "new", auto_iterated: "auto_iterated",
  1094 +};
  1095 +
  1096 +function renderSkillLibrary() {
  1097 + return `
  1098 + <div class="page page-wide">
  1099 + <h1>技能库</h1>
  1100 + <div id="skill_groups"><div class="hint">加载中…</div></div>
  1101 + </div>
  1102 + `;
  1103 +}
  1104 +
  1105 +function mountSkillLibrary() {
  1106 + const groupsEl = document.getElementById("skill_groups");
  1107 +
  1108 + fetch("/api/skills").then((r) => r.json()).then((skills) => {
  1109 + if (skills.length === 0) {
  1110 + groupsEl.innerHTML = '<div class="hint">暂无 Skill</div>';
  1111 + return;
  1112 + }
  1113 + const byOrgNode = { planner: [], coder: [], validator: [] };
  1114 + for (const s of skills) (byOrgNode[s.owner_org_node] || []).push(s);
  1115 +
  1116 + groupsEl.innerHTML = ORG_NODE_ORDER.map((node) => {
  1117 + const rows = byOrgNode[node];
  1118 + if (!rows || rows.length === 0) return "";
  1119 + const rowsHtml = rows.map((s) =>
  1120 + '<div class="skill-row" data-skill-id="' + s.skill_id + '">'
  1121 + + '<span class="name">' + escapeHtml(s.skill_id) + "</span>"
  1122 + + '<span class="tag' + (s.source === "auto_iterated" ? " auto" : "") + '">'
  1123 + + escapeHtml(SOURCE_TAG_LABELS[s.source] || s.source) + "</span>"
  1124 + + "</div>"
  1125 + ).join("");
  1126 + return (
  1127 + '<div class="group"><h2>' + node.charAt(0).toUpperCase() + node.slice(1) + "</h2>"
  1128 + + '<div class="skill-list">' + rowsHtml + "</div></div>"
  1129 + );
  1130 + }).join("");
  1131 +
  1132 + for (const row of groupsEl.querySelectorAll(".skill-row")) {
  1133 + row.addEventListener("click", () => {
  1134 + window.location.hash = "#/skills/" + row.dataset.skillId + "/audit";
  1135 + });
  1136 + }
  1137 + });
  1138 +}
  1139 +
  1140 +function renderSkillAuditLog(skillId) {
  1141 + return `
  1142 + <div class="page page-wide">
  1143 + <div class="breadcrumb"><a href="#/skills">技能库</a> / ${escapeHtml(skillId)}</div>
  1144 + <h1>${escapeHtml(skillId)} 变更审计</h1>
  1145 + <div id="skill_audit_error" class="error-banner" style="display:none;"></div>
  1146 + <div id="skill_audit_list"><div class="hint">加载中…</div></div>
  1147 + </div>
  1148 + `;
  1149 +}
  1150 +
  1151 +function mountSkillAuditLog(skillId) {
  1152 + const listEl = document.getElementById("skill_audit_list");
  1153 + const errorEl = document.getElementById("skill_audit_error");
  1154 +
  1155 + async function refresh() {
  1156 + const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/audit");
  1157 + if (resp.status === 404) {
  1158 + listEl.innerHTML = "";
  1159 + errorEl.textContent = "该 Skill 不存在";
  1160 + errorEl.style.display = "block";
  1161 + return;
  1162 + }
  1163 + const iterations = await resp.json();
  1164 + if (iterations.length === 0) {
  1165 + listEl.innerHTML = '<div class="hint">该 Skill 尚无自主迭代记录</div>';
  1166 + return;
  1167 + }
  1168 + // newest first for display, oldest-first order from the API gives each its #N
  1169 + listEl.innerHTML = iterations.slice().reverse().map((it, i) => {
  1170 + const number = iterations.length - i;
  1171 + return (
  1172 + '<div class="iteration" data-iteration-id="' + it.iteration_id + '" '
  1173 + + 'data-before="' + it.before_git_commit_sha + '" data-after="' + it.after_git_commit_sha + '">'
  1174 + + '<div class="top"><b>迭代 #' + number + "</b><span class=\"t\">applied_at: " + escapeHtml(it.applied_at) + "</span></div>"
  1175 + + '<div class="summary">triggered_by_bug_id: ' + escapeHtml(it.triggered_by_bug_id) + " · " + escapeHtml(it.change_summary) + "</div>"
  1176 + + '<div class="actions">'
  1177 + + '<button class="btn btn-diff" type="button">查看 diff</button>'
  1178 + + '<button class="btn btn-rollback" type="button">回滚到此版本前</button>'
  1179 + + "</div>"
  1180 + + '<pre class="code diff-output" style="display:none;"></pre>'
  1181 + + "</div>"
  1182 + );
  1183 + }).join("");
  1184 +
  1185 + for (const el of listEl.querySelectorAll(".iteration")) {
  1186 + const diffOutput = el.querySelector(".diff-output");
  1187 + el.querySelector(".btn-diff").addEventListener("click", async () => {
  1188 + if (diffOutput.style.display === "block") {
  1189 + diffOutput.style.display = "none";
  1190 + return;
  1191 + }
  1192 + diffOutput.textContent = "加载中…";
  1193 + diffOutput.style.display = "block";
  1194 + const resp = await fetch(
  1195 + "/api/skills/diff?before=" + encodeURIComponent(el.dataset.before) + "&after=" + encodeURIComponent(el.dataset.after)
  1196 + );
  1197 + const body = await resp.json();
  1198 + diffOutput.textContent = body.diff_text || "(无差异)";
  1199 + });
  1200 + el.querySelector(".btn-rollback").addEventListener("click", async () => {
  1201 + const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/rollback", {
  1202 + method: "POST",
  1203 + body: JSON.stringify({ iteration_id: el.dataset.iterationId }),
  1204 + });
  1205 + if (resp.status !== 200) {
  1206 + const body = await resp.json();
  1207 + errorEl.textContent = body.error + (body.detail ? ":" + body.detail : "");
  1208 + errorEl.style.display = "block";
  1209 + return;
  1210 + }
  1211 + errorEl.style.display = "none";
  1212 + refresh();
  1213 + });
  1214 + }
  1215 + }
  1216 +
  1217 + refresh();
  1218 +}
... ...
frontend/index.html
... ... @@ -10,6 +10,7 @@
10 10 <a href="#/">Dashboard</a>
11 11 <a href="#/tasks">Tasks</a>
12 12 <a href="#/artifacts">Artifacts</a>
  13 + <a href="#/skills">Skills</a>
13 14 <a href="#/settings">Settings</a>
14 15 </nav>
15 16 <main id="app"></main>
... ...
frontend/style.css
... ... @@ -138,3 +138,21 @@ ul.artifact-list .type { font-size: 11px; color: #6b7280; text-transform: upperc
138 138 .lineage { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 12px; color: #6b7280; margin-bottom: 14px; }
139 139 .lineage span.node { border: 1px solid #e5e7eb; border-radius: 999px; padding: 3px 10px; background: #fff; }
140 140 pre.code { background: #0b1120; color: #d1d5db; padding: 14px; border-radius: 8px; font-size: 12px; overflow: auto; white-space: pre-wrap; }
  141 +
  142 +/* U-029: v_skill_library / v_skill_audit_log -- class names match
  143 + docs/wireframes/v_skill_library.html, v_skill_audit_log.html */
  144 +.group { margin-bottom: 22px; }
  145 +.group h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: #6b7280; margin: 0 0 10px; }
  146 +.skill-list { border: 1px solid #e5e7eb; border-radius: 10px; overflow: hidden; }
  147 +.skill-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; font-size: 13px; border-bottom: 1px solid #e5e7eb; cursor: pointer; }
  148 +.skill-row:last-child { border-bottom: none; }
  149 +.skill-row:hover { background: #f9fafb; }
  150 +.skill-row .name { font-weight: 600; }
  151 +.tag { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: #eef2ff; color: #4f46e5; }
  152 +.tag.auto { background: #fef3c7; color: #92400e; }
  153 +.iteration { border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; background: #f9fafb; }
  154 +.iteration .top { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; }
  155 +.iteration .t { color: #6b7280; font-size: 12px; }
  156 +.iteration .summary { font-size: 13px; margin-bottom: 8px; }
  157 +.iteration .actions { display: flex; gap: 8px; }
  158 +.iteration .diff-output { margin-top: 10px; }
... ...
src/backend_server.py
... ... @@ -16,10 +16,12 @@ no `websockets` dependency) for real-time `execution_event` push,
16 16 `c_execution_log_get`/`c_ws_log_tail` (03-C §3.6.1/§3.6.2, U-027, the
17 17 latter reusing the same hand-rolled WS machinery) for raw execution log
18 18 viewing, `c_artifact_list`/`c_artifact_get` (U-028, the former unnamed in
19   -either 03-C or the MVCP JSON contract) for `v_artifact_browser`, and
20   -serving the U-008 frontend shell as static files (no build step, 05 §5.7:
21   -whitelisted by exact path, not a generic directory walker, so there's no
22   -path-traversal surface to reason about).
  19 +either 03-C or the MVCP JSON contract) for `v_artifact_browser`,
  20 +`c_skill_diff`/`c_skill_rollback` + skill/skill_iteration list/get (U-029,
  21 +`c_skill_diff` likewise only named in the MVCP JSON) for `v_skill_library`/
  22 +`v_skill_audit_log`, and serving the U-008 frontend shell as static files
  23 +(no build step, 05 §5.7: whitelisted by exact path, not a generic
  24 +directory walker, so there's no path-traversal surface to reason about).
23 25 """
24 26  
25 27 import json
... ... @@ -34,8 +36,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
34 36  
35 37 from src import (
36 38 artifact_query, budget_guardrail, bug_record, execution_log,
37   - org_loop_route, paths, project_scaffold, schema, task_create,
38   - task_query, ws_subscribe,
  39 + org_loop_route, paths, project_scaffold, schema, skill_query,
  40 + skill_update, task_create, task_query, ws_subscribe,
39 41 )
40 42  
41 43 _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
... ... @@ -122,6 +124,7 @@ _WORK_NODES_PREFIX = &quot;/api/work-nodes/&quot;
122 124 _WS_WORK_NODES_PREFIX = "/api/ws/work-nodes/"
123 125 _LOG_SUFFIX = "/log"
124 126 _ARTIFACTS_PREFIX = "/api/artifacts/"
  127 +_SKILLS_PREFIX = "/api/skills/"
125 128  
126 129  
127 130 class _Handler(BaseHTTPRequestHandler):
... ... @@ -157,6 +160,16 @@ class _Handler(BaseHTTPRequestHandler):
157 160 if artifact_id and "/" not in artifact_id:
158 161 self._handle_artifact_get(artifact_id)
159 162 return
  163 + if parsed.path == "/api/skills":
  164 + self._handle_skill_list()
  165 + return
  166 + if parsed.path == "/api/skills/diff":
  167 + self._handle_skill_diff(parsed.query)
  168 + return
  169 + if parsed.path.startswith(_SKILLS_PREFIX) and parsed.path.endswith("/audit"):
  170 + skill_id = parsed.path[len(_SKILLS_PREFIX):-len("/audit")]
  171 + self._handle_skill_audit_log(skill_id)
  172 + return
160 173  
161 174 entry = _STATIC_FILES.get(self.path)
162 175 if entry is None:
... ... @@ -183,6 +196,9 @@ class _Handler(BaseHTTPRequestHandler):
183 196 elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/route"):
184 197 task_id = self.path[len(_TASKS_PREFIX):-len("/route")]
185 198 self._handle_task_route(task_id)
  199 + elif self.path.startswith(_SKILLS_PREFIX) and self.path.endswith("/rollback"):
  200 + skill_id = self.path[len(_SKILLS_PREFIX):-len("/rollback")]
  201 + self._handle_skill_rollback(skill_id)
186 202 else:
187 203 self._send_json(404, {"error": "NOT_FOUND"})
188 204  
... ... @@ -335,6 +351,51 @@ class _Handler(BaseHTTPRequestHandler):
335 351 else:
336 352 self._send_json(200, result)
337 353  
  354 + def _handle_skill_list(self):
  355 + with self.server.conn_lock:
  356 + result = skill_query.list_skills(self.server.conn)
  357 + self._send_json(200, result)
  358 +
  359 + def _handle_skill_audit_log(self, skill_id):
  360 + try:
  361 + with self.server.conn_lock:
  362 + result = skill_query.get_skill_audit_log(self.server.conn, skill_id)
  363 + except skill_query.SkillNotFoundError:
  364 + self._send_json(404, {"error": "NOT_FOUND"})
  365 + else:
  366 + self._send_json(200, result)
  367 +
  368 + def _handle_skill_diff(self, query_string):
  369 + params = urllib.parse.parse_qs(query_string)
  370 + before = params.get("before", [None])[0]
  371 + after = params.get("after", [None])[0]
  372 + if not before or not after:
  373 + self._send_json(400, {"error": "INVALID_INPUT", "detail": "before and after are both required"})
  374 + return
  375 + try:
  376 + diff_text = skill_query.get_diff(before, after, repo_path=self.server.skill_repo_path)
  377 + except subprocess.CalledProcessError as e:
  378 + self._send_json(400, {"error": "INVALID_PARAM", "detail": e.stderr})
  379 + else:
  380 + self._send_json(200, {"diff_text": diff_text})
  381 +
  382 + def _handle_skill_rollback(self, skill_id):
  383 + payload, error = self._read_json_body()
  384 + if error:
  385 + self._send_json(400, error)
  386 + return
  387 + iteration_id = payload.get("iteration_id")
  388 + if not iteration_id:
  389 + self._send_json(400, {"error": "INVALID_INPUT", "detail": "iteration_id is required"})
  390 + return
  391 + try:
  392 + with self.server.conn_lock:
  393 + result = skill_update.rollback_skill(self.server.conn, iteration_id, self.server.skill_repo_path)
  394 + except skill_update.IterationNotFoundError as e:
  395 + self._send_json(404, {"error": "ITERATION_NOT_FOUND", "detail": str(e)})
  396 + else:
  397 + self._send_json(200, result)
  398 +
338 399 def _handle_ws_subscribe(self, path, query_string):
339 400 """c_ws_subscribe (03-C §3.14): WS /api/ws/projects/:project_id.
340 401 Rejects with a normal HTTP response (no 101) before ever touching
... ... @@ -514,16 +575,25 @@ class _Server(ThreadingHTTPServer):
514 575 self.conn.close()
515 576  
516 577  
517   -def create_server(host="127.0.0.1", port=0, db_path=None):
  578 +def create_server(host="127.0.0.1", port=0, db_path=None, skill_repo_path=None):
518 579 """port=0 lets the OS assign a free port -- avoids collisions in tests
519 580 and lets multiple graphlemon instances run side by side if ever needed.
520 581  
521 582 db_path defaults to paths.db_path() (U-003's platform_home, so it never
522 583 lives inside a target project's own repo); tests pass ":memory:" or a
523   - tempfile so runs don't touch the real developer's ~/.graphlemon/."""
  584 + tempfile so runs don't touch the real developer's ~/.graphlemon/.
  585 +
  586 + skill_repo_path defaults to skill_query._REPO_ROOT (graphlemon's own
  587 + checkout -- Skills genuinely live there, U-018/U-029) but is injectable
  588 + for the same reason db_path is: c_skill_rollback does a real `git
  589 + commit`, and a test hitting that route with the real default would
  590 + write an actual commit into whatever repo this process happens to be
  591 + running from. Never exposed to HTTP clients -- only settable here, at
  592 + process/test-construction time."""
524 593 conn = schema.connect(db_path if db_path is not None else paths.db_path(), check_same_thread=False)
525 594 _ensure_schema(conn)
526 595 server = _Server((host, port), _Handler)
527 596 server.conn = conn
528 597 server.conn_lock = threading.Lock()
  598 + server.skill_repo_path = skill_repo_path if skill_repo_path is not None else skill_query._REPO_ROOT
529 599 return server
... ...
src/skill_query.py 0 → 100644
  1 +"""c_skill_diff (named only in `sdd-mvcp-contract.json`, prose 03-C never
  2 +wrote it up) + list/get read helpers for `v_skill_library`/`v_skill_audit_log`
  3 +(02-V §2.9/§2.10). Skills live in graphlemon's own repo, not any target
  4 +project's (`src/skill_update.py`'s own docstring: "Skill 库是platform
  5 +asset,不是target-project"), so the diff runs against graphlemon's own
  6 +checkout by default -- reuses `agent_profile.py`'s own `_REPO_ROOT` pattern
  7 +rather than inventing a second way to locate it.
  8 +"""
  9 +
  10 +import os
  11 +import subprocess
  12 +
  13 +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  14 +
  15 +
  16 +def list_skills(conn):
  17 + rows = conn.execute(
  18 + "SELECT skill_id, owner_org_node, file_path, current_git_commit_sha, source, "
  19 + "migrated_from_profile, status FROM skill ORDER BY owner_org_node, skill_id"
  20 + ).fetchall()
  21 + return [
  22 + {
  23 + "skill_id": r[0], "owner_org_node": r[1], "file_path": r[2], "current_git_commit_sha": r[3],
  24 + "source": r[4], "migrated_from_profile": r[5], "status": r[6],
  25 + }
  26 + for r in rows
  27 + ]
  28 +
  29 +
  30 +class SkillNotFoundError(Exception):
  31 + """NOT_FOUND -- c_skill_get's own skill_id doesn't exist."""
  32 +
  33 +
  34 +def get_skill_audit_log(conn, skill_id):
  35 + """v_skill_audit_log (02-V §2.10): every skill_iteration row for this
  36 + skill, oldest first."""
  37 + if conn.execute("SELECT 1 FROM skill WHERE skill_id = ?", (skill_id,)).fetchone() is None:
  38 + raise SkillNotFoundError(skill_id)
  39 + rows = conn.execute(
  40 + "SELECT iteration_id, triggered_by_bug_id, before_git_commit_sha, after_git_commit_sha, "
  41 + "change_summary, applied_at, rollback_of_iteration_id FROM skill_iteration "
  42 + "WHERE skill_id = ? ORDER BY applied_at",
  43 + (skill_id,),
  44 + ).fetchall()
  45 + return [
  46 + {
  47 + "iteration_id": r[0], "triggered_by_bug_id": r[1], "before_git_commit_sha": r[2],
  48 + "after_git_commit_sha": r[3], "change_summary": r[4], "applied_at": r[5],
  49 + "rollback_of_iteration_id": r[6],
  50 + }
  51 + for r in rows
  52 + ]
  53 +
  54 +
  55 +def get_diff(before, after, repo_path=None):
  56 + """c_skill_diff: `git diff <before> <after>` -- skill_update.py's
  57 + _write_and_commit makes every iteration an atomic single-file commit,
  58 + so a plain repo-wide diff between the two commits naturally scopes
  59 + itself to that one skill file without needing a path filter."""
  60 + result = subprocess.run(
  61 + ["git", "diff", before, after], cwd=repo_path or _REPO_ROOT,
  62 + capture_output=True, text=True, check=True,
  63 + )
  64 + return result.stdout
... ...
src/test_backend_server.py
... ... @@ -20,7 +20,7 @@ import time
20 20 import unittest
21 21 from unittest.mock import patch
22 22  
23   -from src import backend_server, execution_log, ws_subscribe
  23 +from src import backend_server, execution_log, skill_update, ws_subscribe
24 24  
25 25  
26 26 class ServerStartStopTest(unittest.TestCase):
... ... @@ -887,6 +887,106 @@ class ArtifactRouteTest(unittest.TestCase):
887 887 self.assertEqual(body["error"], "NOT_FOUND")
888 888  
889 889  
  890 +class SkillRouteTest(unittest.TestCase):
  891 + """U-029: c_skill_diff (GET /api/skills/diff) + c_skill_rollback
  892 + (POST /api/skills/:skill_id/rollback) + list/get for v_skill_library /
  893 + v_skill_audit_log. skill_repo_path points at a throwaway temp repo --
  894 + NEVER the real graphlemon checkout, since c_skill_rollback does a real
  895 + `git commit` (see backend_server.create_server's skill_repo_path note)."""
  896 +
  897 + def setUp(self):
  898 + self.tmp = tempfile.TemporaryDirectory()
  899 + self.addCleanup(self.tmp.cleanup)
  900 + subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True)
  901 + self.server = backend_server.create_server(db_path=":memory:", skill_repo_path=self.tmp.name)
  902 + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  903 + self.thread.start()
  904 + self.server.conn.execute(
  905 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  906 + "VALUES ('skill-1', 'coder', 'skills/skill-1', 'unknown', 'new', 'active')"
  907 + )
  908 + self.server.conn.execute(
  909 + "INSERT INTO bug_record (bug_id, source, description, root_cause_category, root_cause_detail, "
  910 + "suggested_route, status) VALUES ('bug-1', 'validator', 'x', 'skill_defect', 'd', 'fast_lane', 'routed')"
  911 + )
  912 + self.server.conn.commit()
  913 +
  914 + def tearDown(self):
  915 + self.server.shutdown()
  916 + self.server.server_close()
  917 + self.thread.join(timeout=5)
  918 +
  919 + def _get(self, path):
  920 + host, port = self.server.server_address
  921 + conn = http.client.HTTPConnection(host, port, timeout=5)
  922 + conn.request("GET", path)
  923 + resp = conn.getresponse()
  924 + body = json.loads(resp.read())
  925 + conn.close()
  926 + return resp.status, body
  927 +
  928 + def _post(self, path, payload):
  929 + host, port = self.server.server_address
  930 + conn = http.client.HTTPConnection(host, port, timeout=5)
  931 + conn.request("POST", path, body=json.dumps(payload).encode("utf-8"))
  932 + resp = conn.getresponse()
  933 + body = json.loads(resp.read())
  934 + conn.close()
  935 + return resp.status, body
  936 +
  937 + def test_list_returns_seeded_skill(self):
  938 + status, body = self._get("/api/skills")
  939 + self.assertEqual(status, 200)
  940 + self.assertEqual(len(body), 1)
  941 + self.assertEqual(body[0]["skill_id"], "skill-1")
  942 +
  943 + def test_audit_log_empty_for_skill_with_no_iterations(self):
  944 + status, body = self._get("/api/skills/skill-1/audit")
  945 + self.assertEqual(status, 200)
  946 + self.assertEqual(body, [])
  947 +
  948 + def test_audit_log_nonexistent_skill_returns_404(self):
  949 + status, body = self._get("/api/skills/no-such-skill/audit")
  950 + self.assertEqual(status, 404)
  951 +
  952 + def test_diff_missing_query_params_returns_400(self):
  953 + status, body = self._get("/api/skills/diff")
  954 + self.assertEqual(status, 400)
  955 + self.assertEqual(body["error"], "INVALID_INPUT")
  956 +
  957 + def test_full_update_diff_rollback_cycle(self):
  958 + skill_update._write_and_commit(self.tmp.name, "skills/skill-1/SKILL.md", "v1 content", "seed skill")
  959 + with self.server.conn_lock:
  960 + result = skill_update.update_skill(
  961 + self.server.conn, "skill-1", "bug-1", "v2 content", "improve it", self.tmp.name
  962 + )
  963 + iteration_id = result["iteration_id"]
  964 +
  965 + status, body = self._get(
  966 + "/api/skills/diff?before=" + self._before_sha(iteration_id) + "&after=" + result["after_git_commit_sha"]
  967 + )
  968 + self.assertEqual(status, 200)
  969 + self.assertIn("v2 content", body["diff_text"])
  970 +
  971 + status, body = self._post("/api/skills/skill-1/rollback", {"iteration_id": iteration_id})
  972 + self.assertEqual(status, 200)
  973 + self.assertIn("new_iteration_id", body)
  974 +
  975 + def _before_sha(self, iteration_id):
  976 + return self.server.conn.execute(
  977 + "SELECT before_git_commit_sha FROM skill_iteration WHERE iteration_id = ?", (iteration_id,)
  978 + ).fetchone()[0]
  979 +
  980 + def test_rollback_nonexistent_iteration_returns_404(self):
  981 + status, body = self._post("/api/skills/skill-1/rollback", {"iteration_id": "no-such-iteration"})
  982 + self.assertEqual(status, 404)
  983 + self.assertEqual(body["error"], "ITERATION_NOT_FOUND")
  984 +
  985 + def test_rollback_missing_iteration_id_returns_400(self):
  986 + status, body = self._post("/api/skills/skill-1/rollback", {})
  987 + self.assertEqual(status, 400)
  988 +
  989 +
890 990 class ZeroThirdPartyDependencyTest(unittest.TestCase):
891 991 """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this
892 992 doesn't silently rot if someone adds an import later."""
... ...
src/test_skill_query.py 0 → 100644
  1 +"""U-029 tests: c_skill_diff + list/get read helpers for v_skill_library /
  2 +v_skill_audit_log. Real sqlite + real git in a throwaway temp repo (never
  3 +graphlemon's own -- same reasoning as test_skill_update.py).
  4 +
  5 +Run: python3 -m unittest src.test_skill_query -v
  6 +"""
  7 +
  8 +import subprocess
  9 +import tempfile
  10 +import unittest
  11 +
  12 +from src import schema, skill_query, skill_update
  13 +
  14 +
  15 +def _init_git_repo(path):
  16 + subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
  17 +
  18 +
  19 +class SkillQueryTestBase(unittest.TestCase):
  20 + def setUp(self):
  21 + self.tmp = tempfile.TemporaryDirectory()
  22 + self.addCleanup(self.tmp.cleanup)
  23 + _init_git_repo(self.tmp.name)
  24 +
  25 + self.conn = schema.connect(":memory:")
  26 + schema.create_schema(self.conn)
  27 + self.conn.commit()
  28 +
  29 + def tearDown(self):
  30 + self.conn.close()
  31 +
  32 + def _insert_skill(self, skill_id, owner_org_node, source="new", migrated_from_profile=None):
  33 + self.conn.execute(
  34 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, "
  35 + "migrated_from_profile, status) VALUES (?, ?, ?, 'unknown', ?, ?, 'active')",
  36 + (skill_id, owner_org_node, "skills/" + skill_id, source, migrated_from_profile),
  37 + )
  38 + self.conn.commit()
  39 +
  40 +
  41 +class ListSkillsTest(SkillQueryTestBase):
  42 + def test_lists_all_skills(self):
  43 + self._insert_skill("s1", "planner")
  44 + self._insert_skill("s2", "coder")
  45 + result = skill_query.list_skills(self.conn)
  46 + self.assertEqual({s["skill_id"] for s in result}, {"s1", "s2"})
  47 +
  48 + def test_includes_owner_org_node_and_source(self):
  49 + self._insert_skill("s1", "validator", source="migrated_from_hermes", migrated_from_profile="qa")
  50 + result = skill_query.list_skills(self.conn)
  51 + self.assertEqual(result[0]["owner_org_node"], "validator")
  52 + self.assertEqual(result[0]["source"], "migrated_from_hermes")
  53 + self.assertEqual(result[0]["migrated_from_profile"], "qa")
  54 +
  55 + def test_empty_returns_empty_list(self):
  56 + self.assertEqual(skill_query.list_skills(self.conn), [])
  57 +
  58 +
  59 +class GetSkillAuditLogTest(SkillQueryTestBase):
  60 + def test_nonexistent_skill_raises(self):
  61 + with self.assertRaises(skill_query.SkillNotFoundError):
  62 + skill_query.get_skill_audit_log(self.conn, "no-such-skill")
  63 +
  64 + def test_skill_with_no_iterations_returns_empty_list(self):
  65 + self._insert_skill("s1", "planner")
  66 + self.assertEqual(skill_query.get_skill_audit_log(self.conn, "s1"), [])
  67 +
  68 + def test_returns_iterations_oldest_first(self):
  69 + self._insert_skill("s1", "coder")
  70 + self.conn.execute(
  71 + "INSERT INTO bug_record (bug_id, source, description, root_cause_category, root_cause_detail, "
  72 + "suggested_route, status) VALUES ('bug-1', 'validator', 'x', 'skill_defect', 'd', 'fast_lane', 'routed')"
  73 + )
  74 + self.conn.execute(
  75 + "INSERT INTO skill_iteration (iteration_id, skill_id, triggered_by_bug_id, before_git_commit_sha, "
  76 + "after_git_commit_sha, change_summary, applied_at) VALUES "
  77 + "('it-1', 's1', 'bug-1', 'sha0', 'sha1', 'fix A', '2026-07-01 00:00:00')"
  78 + )
  79 + self.conn.execute(
  80 + "INSERT INTO skill_iteration (iteration_id, skill_id, triggered_by_bug_id, before_git_commit_sha, "
  81 + "after_git_commit_sha, change_summary, applied_at) VALUES "
  82 + "('it-2', 's1', 'bug-1', 'sha1', 'sha2', 'fix B', '2026-07-02 00:00:00')"
  83 + )
  84 + self.conn.commit()
  85 + result = skill_query.get_skill_audit_log(self.conn, "s1")
  86 + self.assertEqual([r["iteration_id"] for r in result], ["it-1", "it-2"])
  87 + self.assertEqual(result[0]["change_summary"], "fix A")
  88 +
  89 +
  90 +class GetDiffTest(SkillQueryTestBase):
  91 + def test_diff_between_two_commits_shows_content_change(self):
  92 + before_sha = skill_update._write_and_commit(self.tmp.name, "skills/s1/SKILL.md", "v1 content\n", "seed")
  93 + after_sha = skill_update._write_and_commit(self.tmp.name, "skills/s1/SKILL.md", "v2 content\n", "update")
  94 + diff_text = skill_query.get_diff(before_sha, after_sha, repo_path=self.tmp.name)
  95 + self.assertIn("-v1 content", diff_text)
  96 + self.assertIn("+v2 content", diff_text)
  97 +
  98 + def test_identical_commits_produce_empty_diff(self):
  99 + sha = skill_update._write_and_commit(self.tmp.name, "skills/s1/SKILL.md", "same\n", "seed")
  100 + diff_text = skill_query.get_diff(sha, sha, repo_path=self.tmp.name)
  101 + self.assertEqual(diff_text, "")
  102 +
  103 +
  104 +if __name__ == "__main__":
  105 + unittest.main()
... ...