diff --git a/.changepacks/changepack_log_Rt4nW8cKe2JpX6vHy9ZgA.json b/.changepacks/changepack_log_Rt4nW8cKe2JpX6vHy9ZgA.json new file mode 100644 index 00000000..3ae83469 --- /dev/null +++ b/.changepacks/changepack_log_Rt4nW8cKe2JpX6vHy9ZgA.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Minor" + }, + "note": "Add the /devup-retro command: a session retrospective that lists the follow-up prompts the user had to write after devup-mcp's work, classifies each by the surface that should have prevented it, asks the user to confirm, and then prints ready-to-paste improvement prompts (optionally saved as .feedback records). Ships as the devup-retro skill (Claude Code /devup-retro, OpenCode command file), as an MCP prompt of the same name for workspaces without the skill, and with a read-only devup_skills action `retro` that returns the installed build, this process's devup-mcp call log with timestamps, the surface list and the .feedback folder state.", + "date": "2026-11-10T00:00:00.000000000Z" +} diff --git a/README.md b/README.md index 3e8fc8ef..3d555226 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Rust-native MCP server that reads Figma designs and generates DevupUI artifacts. - `devup_figma_search`: page, section, frame, component를 이름으로 탐색. URL에 `node-id`가 있으면 **그 노드와 그 아래로 범위를 좁히고**, 없으면 파일 전체를 검색합니다. 둘 중 무엇을 했는지는 응답의 `scope`가 알려줍니다 - `devup_figma_explore`: 링크된 요구사항/라벨 주변의 실제 화면 후보를 공간 순서로 탐색 - `devup_figma_auth`: Figma에 닿는 **두 경로**(브리지 플러그인·direct OAuth)를 함께 보고(`status`) — 붙어 있는 플러그인의 파일·현재 페이지·선택까지. 그 밖에 브라우저 OAuth 로그인, 로그아웃, 사전 등록 자격증명 주입(`configure`), 참조 데이터까지 붙인 `doctor`. **로그인을 요청하기 전에 `status`부터 부르세요** — 플러그인이 붙어 있으면 로그인은 필요 없습니다 -- `devup_skills`: devup-mcp가 내놓는 코드에 필요한 에이전트 스킬이 이 워크스페이스에 있는지 보고(`status`)하고, devup-mcp가 품고 있는 것을 설치(`install`). **텍스트를 응답에 실어 보내는 게 아니라 스킬 디렉터리에 설치해서 에이전트 자신의 로더가 읽게 합니다** — 한 번 읽은 문서는 한 번 쓰이지만, 설치된 스킬은 이후 모든 세션에 계속 적용됩니다. 어느 디렉터리인지는 `clientInfo`로 판별한 런타임이 정하고, `projectRoot`로 어느 프로젝트인지 지정합니다 +- `devup_skills`: devup-mcp가 내놓는 코드에 필요한 에이전트 스킬이 이 워크스페이스에 있는지 보고(`status`)하고, devup-mcp가 품고 있는 것을 설치(`install`). **텍스트를 응답에 실어 보내는 게 아니라 스킬 디렉터리에 설치해서 에이전트 자신의 로더가 읽게 합니다** — 한 번 읽은 문서는 한 번 쓰이지만, 설치된 스킬은 이후 모든 세션에 계속 적용됩니다. 어느 디렉터리인지는 `clientInfo`로 판별한 런타임이 정하고, `projectRoot`로 어느 프로젝트인지 지정합니다 `retro`는 작업 뒤 회고 명령 `/devup-retro`가 쓰는 읽기 전용 액션입니다(아래 “작업 뒤 회고” 참고). - `devup_project_context`: 프로젝트의 실제 `devup.json` 토큰, `openapi.json` 엔드포인트, Vespertide 모델을 읽음. 중첩 체크아웃과 빌드 산출물 디렉터리는 스캔에서 제외하고 무엇을 제외했는지 보고 - `devup_ui_validate`: 생성한 TSX를 프로젝트의 실제 `devup.json`에 대조해 검증. `ok`는 개수가 아니라 심각도로 판정 - `devup_stack_diff`: DB 모델부터 생성된 API 클라이언트까지의 층간 드리프트 탐지. 모든 발견은 명시적 `confidence`를 가짐 @@ -1107,6 +1107,14 @@ DevupUI가 audit 속성을 기본으로 제거하며 `DEVUP_AUDIT=1`이면 유 명시적 시작점 `/devup [figma url] [notes]`: `devup_skills install`이 `devup` 스킬을 다른 스킬과 함께 쓰고(Claude Code는 프로젝트 스킬을 같은 이름의 슬래시 명령으로 제공하므로 `.claude/skills/devup/SKILL.md`가 곧 `/devup`), OpenCode에는 `.opencode/command/devup.md`도 씁니다. 명령 위치가 검증되지 않은 다른 런타임은 지원하지 않는다고 보고합니다. `devup_skills status`의 `command`가 설치 여부를 알려 줍니다. `/devup` 없이도 Figma로 페이지나 사이트를 만들어 달라는 요청이면 같은 흐름을 시작합니다. +작업 뒤 회고 `/devup-retro [범위 또는 메모]`: devup-mcp로 한 작업이 끝난 뒤 한 번 입력하면, devup-mcp가 처음 호출된 시점부터 지금까지의 대화에서 사용자가 결과를 고치거나 마무리하려고 직접 써야 했던 후속 프롬프트(“애니메이션을 추가해야 함”, “UI가 잘못됐어”, 간격·너비, 한글 줄바꿈, 누락된 상태, 잘못된 에셋, 실행하지 않은 테스트, 틀린 CI 증거 등)를 모읍니다. 세 단계이며 합치지 않습니다. + +1. **review**: 항목마다 사용자 프롬프트 원문 인용, 무엇이 잘못됐는지, 증거(파일·노드·도구 출력), 그리고 그것을 막았어야 할 surface 하나(`codegen`, `export-output`, `validation-rule`, `runtime-audit`, `skill-guidance`, `bootstrap-instructions`, `project-plan-intake`, `out-of-scope:devup-ui|app|figma-data`)를 적습니다. 사용자 프롬프트 증거가 없는 항목은 넣지 않고, 설치된 버전에서 이미 고쳐진 항목은 `fixed-in-installed`/`fixed-not-installed`/`already-recorded`로 표시합니다(릴리스 노트를 읽지 못하면 `unverified`). +2. **confirm**: 목록을 표로 보여 주고 질문 도구로 `OK / Edit items / Drop items / Cancel`을 묻습니다. 확인 전에는 아무것도 쓰지 않고 개선 프롬프트도 출력하지 않습니다. 프로젝트에 `.feedback/` 폴더가 있을 때만 기록 저장 여부를 함께 묻습니다. +3. **prompts**: `OK` 뒤에 surface별로 붙여 넣을 수 있는 devup-mcp 개선 프롬프트를 사용자 언어로 출력합니다. 각 프롬프트는 목표, 구체적 필수 동작, 수용 기준·회귀 테스트, 범위 경계를 가집니다. 저장을 선택했다면 `.feedback/`의 기존 형식·번호·index를 그대로 따라 항목별 기록을 추가합니다. + +`/devup`과 같은 경로로 배포됩니다: `devup_skills install`이 `devup-retro` 스킬을 쓰고(Claude Code에서는 `.claude/skills/devup-retro/SKILL.md`가 곧 `/devup-retro`, Codex 등은 `.agents/skills`에서 이름으로 로드), OpenCode에는 스킬 본문으로 만든 `.opencode/command/devup-retro.md`도 씁니다. 설치하지 않은 워크스페이스에서는 MCP 프롬프트 `devup-retro`(인자 `scope`, `language`, 둘 다 선택; Claude Code에서는 `/mcp__<서버 이름>__devup-retro`)로 같은 본문을 받습니다. `devup_skills`의 `retro` 액션은 읽기 전용으로 설치된 빌드(`server`), 이 서버 프로세스가 처리한 devup-mcp 호출 기록(`callLog`: 도구·action·outputs·시각·성공 여부, 최대 200개, 인자 내용과 결과는 보관하지 않으며 재연결하면 비워짐), surface 목록, `.feedback/` 상태(`nextNumber`, index, 최근 기록)를 돌려줍니다. 명령 이름은 `crates/devup-mcp/src/server/skills/commands.rs`의 `retro_name!` 한 곳에서 정합니다. + 질문 도구에 호출당 개수 제한이 없으면(OpenCode `question`) 평평한 `questions` 목록을 한 번에 묻고, 제한이 있으면(Claude Code `AskUserQuestion`: 4) `maxQuestionsPerRound`(기본 4)로 나눈 `questionRounds`를 연달아 묻습니다. ## 읽기 전용·개인정보 보호 diff --git a/crates/devup-mcp/src/server/handlers/devup_skills.rs b/crates/devup-mcp/src/server/handlers/devup_skills.rs index 88005f98..071c83e6 100644 --- a/crates/devup-mcp/src/server/handlers/devup_skills.rs +++ b/crates/devup-mcp/src/server/handlers/devup_skills.rs @@ -28,9 +28,10 @@ impl DevupServer { result["state"] = skills::report(&lookup); Ok(tool_result(result)) } + "retro" => Ok(tool_result(skills::retro_report(&lookup, &self.call_log))), other => Err(to_mcp_error(DevupError::new( ErrorCode::DevupInvalidInput, - format!("action must be status or install, not {other}."), + format!("action must be status, install or retro, not {other}."), false, ))), } diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index ca3f4fd0..e3f574b8 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -3,4 +3,5 @@ include!("mod_parts/state_and_services.rs"); include!("mod_parts/acquisition.rs"); include!("mod_parts/component_width_acquisition.rs"); include!("mod_parts/exploration_and_responses.rs"); +include!("mod_parts/error_envelopes.rs"); include!("mod_parts/p2_recovery_tests.rs"); diff --git a/crates/devup-mcp/src/server/mod_parts/error_envelopes.rs b/crates/devup-mcp/src/server/mod_parts/error_envelopes.rs new file mode 100644 index 00000000..c116c1ac --- /dev/null +++ b/crates/devup-mcp/src/server/mod_parts/error_envelopes.rs @@ -0,0 +1,94 @@ +/// Maps a [`DevupError`] onto the JSON-RPC error the caller actually sees. +/// +/// The protocol code is not decoration here: the caller is usually an agent +/// choosing between "fix the arguments and call again" and "stop and report", +/// and every error used to arrive as INTERNAL_ERROR, which says the second +/// thing about both. A mistake in the call itself is INVALID_PARAMS, so that +/// decision can be made without parsing the message. `data` keeps carrying +/// the exact `code` and `retryable`, unchanged. +fn to_mcp_error(mut error: DevupError) -> ErrorData { + if error.code == ErrorCode::DevupFigmaHandoffExpired + && error.details.get("exportJob").is_none() + && error.details.get("assetJob").is_none() + { + if !error.details.is_object() { + error.details = json!({"upstreamDetails":error.details}); + } + if error.details.get("stage").is_none() { + error.details["stage"] = json!("artifact-lookup"); + } + if error.details.get("artifactState").is_none() { + error.details["artifactState"] = json!("unknown"); + } + let recovery = error.details.get("recoveryArguments").cloned(); + error.details["recoveryState"] = json!(if recovery.is_some() { + "available" + } else { + "unrecoverable" + }); + let another_process = error.details["artifactState"] == "issued-by-another-process"; + if recovery.is_none() { + error.details["recoveryReason"] = json!(if another_process { + include_str!("../text/mod-to_mcp_error-784239dd.md") + } else { + "Original URL/selection cannot be recovered from this server. The ID may be unknown, evicted beyond metadata retention, or lost on restart; the cause cannot be determined." + }); + } + error.details["recovery"] = json!({ + "mode": "recollect", + "cause": match error.details["artifactState"].as_str() { + Some("expired") => "artifact-expired", + Some("evicted") => "artifact-evicted", + Some("issued-by-another-process") => "issued-by-another-process", + _ => "artifact-unknown", + }, + "reprojectable": false, + "requiresOriginalUrl": recovery.is_none(), + }); + error.details["nextAction"] = json!({"tool":"devup_figma_export", + "arguments":{"refresh":true},"requiredArguments":["url"],"omitArguments":["artifactId"], + "how":include_str!("../text/mod-to_mcp_error-20941350.md")}); + if let Some(arguments) = recovery { + error.details["nextAction"]["arguments"] = arguments; + } + } + if error.details["recoveryArguments"]["nodeIds"].is_array() { + error.details["nextAction"] = json!({"tool":"devup_figma_measure","arguments":error.details["recoveryArguments"], + "how":"Repeat the explicit measured node list; a measurement receipt never recovers by exporting the current selection."}); + } + let mcp_code = if validation::is_caller_mistake(&error) { + McpErrorCode::INVALID_PARAMS + } else { + McpErrorCode::INTERNAL_ERROR + }; + ErrorData::new( + mcp_code, + error.message, + Some( + json!({ "code": error.code, "retryable": error.retryable, "details": error.details, "server":delivery::server_identity() }), + ), + ) +} + +fn structured_tool_error(error: ErrorData) -> rmcp::model::CallToolResult { + let mut data = error.data.unwrap_or_else(|| json!({})); + if !data.is_object() { + data = json!({"details":data}); + } + data.as_object_mut().unwrap().remove("server"); + if data.get("code").is_none() { + data["code"] = json!("DEVUP_INVALID_INPUT"); + } + if data.get("retryable").is_none() { + data["retryable"] = json!(false); + } + if data.get("details").is_none() { + data["details"] = json!({}); + } + data["message"] = json!(error.message); + data["rpcCode"] = json!(error.code); + let mut result = tool_result(json!({"error":data})); + result.is_error = Some(true); + result +} + diff --git a/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs b/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs index 499435aa..55f99adb 100644 --- a/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs +++ b/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs @@ -743,100 +743,6 @@ fn section_retry( )) } -/// Maps a [`DevupError`] onto the JSON-RPC error the caller actually sees. -/// -/// The protocol code is not decoration here: the caller is usually an agent -/// choosing between "fix the arguments and call again" and "stop and report", -/// and every error used to arrive as INTERNAL_ERROR, which says the second -/// thing about both. A mistake in the call itself is INVALID_PARAMS, so that -/// decision can be made without parsing the message. `data` keeps carrying -/// the exact `code` and `retryable`, unchanged. -fn to_mcp_error(mut error: DevupError) -> ErrorData { - if error.code == ErrorCode::DevupFigmaHandoffExpired - && error.details.get("exportJob").is_none() - && error.details.get("assetJob").is_none() - { - if !error.details.is_object() { - error.details = json!({"upstreamDetails":error.details}); - } - if error.details.get("stage").is_none() { - error.details["stage"] = json!("artifact-lookup"); - } - if error.details.get("artifactState").is_none() { - error.details["artifactState"] = json!("unknown"); - } - let recovery = error.details.get("recoveryArguments").cloned(); - error.details["recoveryState"] = json!(if recovery.is_some() { - "available" - } else { - "unrecoverable" - }); - let another_process = error.details["artifactState"] == "issued-by-another-process"; - if recovery.is_none() { - error.details["recoveryReason"] = json!(if another_process { - include_str!("../text/mod-to_mcp_error-784239dd.md") - } else { - "Original URL/selection cannot be recovered from this server. The ID may be unknown, evicted beyond metadata retention, or lost on restart; the cause cannot be determined." - }); - } - error.details["recovery"] = json!({ - "mode": "recollect", - "cause": match error.details["artifactState"].as_str() { - Some("expired") => "artifact-expired", - Some("evicted") => "artifact-evicted", - Some("issued-by-another-process") => "issued-by-another-process", - _ => "artifact-unknown", - }, - "reprojectable": false, - "requiresOriginalUrl": recovery.is_none(), - }); - error.details["nextAction"] = json!({"tool":"devup_figma_export", - "arguments":{"refresh":true},"requiredArguments":["url"],"omitArguments":["artifactId"], - "how":include_str!("../text/mod-to_mcp_error-20941350.md")}); - if let Some(arguments) = recovery { - error.details["nextAction"]["arguments"] = arguments; - } - } - if error.details["recoveryArguments"]["nodeIds"].is_array() { - error.details["nextAction"] = json!({"tool":"devup_figma_measure","arguments":error.details["recoveryArguments"], - "how":"Repeat the explicit measured node list; a measurement receipt never recovers by exporting the current selection."}); - } - let mcp_code = if validation::is_caller_mistake(&error) { - McpErrorCode::INVALID_PARAMS - } else { - McpErrorCode::INTERNAL_ERROR - }; - ErrorData::new( - mcp_code, - error.message, - Some( - json!({ "code": error.code, "retryable": error.retryable, "details": error.details, "server":delivery::server_identity() }), - ), - ) -} - -fn structured_tool_error(error: ErrorData) -> rmcp::model::CallToolResult { - let mut data = error.data.unwrap_or_else(|| json!({})); - if !data.is_object() { - data = json!({"details":data}); - } - data.as_object_mut().unwrap().remove("server"); - if data.get("code").is_none() { - data["code"] = json!("DEVUP_INVALID_INPUT"); - } - if data.get("retryable").is_none() { - data["retryable"] = json!(false); - } - if data.get("details").is_none() { - data["details"] = json!({}); - } - data["message"] = json!(error.message); - data["rpcCode"] = json!(error.code); - let mut result = tool_result(json!({"error":data})); - result.is_error = Some(true); - result -} - #[tool_handler(router = self.tool_router)] impl ServerHandler for DevupServer { async fn call_tool( @@ -854,6 +760,7 @@ impl ServerHandler for DevupServer { let _ = self.client_name.set(peer.client_info.name.clone()); } let mut request = request; + let mut record = self.call_log.begin(request.name.as_ref(), request.arguments.as_ref()); if let (Some(tool), Some(arguments)) = (self.tool_router.get(request.name.as_ref()), request.arguments.as_mut()) { @@ -884,9 +791,27 @@ impl ServerHandler for DevupServer { )); } } + record.finish(!matches!(&response, rmcp::model::CallToolResponse::Complete(result) if result.is_error == Some(true))); Ok(response) } + async fn list_prompts( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(rmcp::model::ListPromptsResult::with_all_items(vec![skills::retro_prompt()])) + } + + async fn get_prompt( + &self, + request: rmcp::model::GetPromptRequestParams, + _context: RequestContext, + ) -> Result { + skills::retro_prompt_result(&request.name, request.arguments.as_ref()) + .ok_or_else(|| ErrorData::invalid_params(format!("Unknown prompt: {}", request.name), None)) + } + fn get_info(&self) -> ServerInfo { // Initialize is the first moment a real session exists, which makes it // the right place to start the release lookup: server construction is @@ -897,6 +822,7 @@ impl ServerHandler for DevupServer { release_check::spawn(); ServerInfo::new( ServerCapabilities::builder() + .enable_prompts() .enable_resources() .enable_tools() .build(), diff --git a/crates/devup-mcp/src/server/mod_parts/state_and_services.rs b/crates/devup-mcp/src/server/mod_parts/state_and_services.rs index 0f33c50f..64d6cc94 100644 --- a/crates/devup-mcp/src/server/mod_parts/state_and_services.rs +++ b/crates/devup-mcp/src/server/mod_parts/state_and_services.rs @@ -432,6 +432,8 @@ pub struct DevupServer { /// not run through, and the skill roots would silently go back to /// guessing. client_name: std::sync::Arc>, + /// The calls this process served, for `devup_skills` action `retro`. + call_log: skills::CallLog, } impl DevupServer { @@ -462,6 +464,7 @@ impl DevupServer { artifacts: ArtifactStore::default(), output_policy: OutputPolicy::from_roots(roots)?, client_name: std::sync::Arc::new(std::sync::OnceLock::new()), + call_log: skills::CallLog::default(), }) } diff --git a/crates/devup-mcp/src/server/skills.rs b/crates/devup-mcp/src/server/skills.rs index 086081c1..650cf67a 100644 --- a/crates/devup-mcp/src/server/skills.rs +++ b/crates/devup-mcp/src/server/skills.rs @@ -28,7 +28,7 @@ //! that copy is moved forward. //! //! `own` skills are written here, in this repository - devfive-frontend, -//! devup-implementation-plan and devup. There +//! devup-implementation-plan, devup and devup-retro. There //! is no upstream to vendor from and nothing for the refresh script to compare //! against, so they pin no commit; this repository's own history is their //! provenance. They are otherwise installed exactly like an embedded skill, @@ -47,6 +47,10 @@ //! ever exported a screen. // Source sections share this module to preserve privacy and test names. +// First: it defines the macro the catalog spells an embedded path with. +include!("skills/commands.rs"); include!("skills/catalog.rs"); include!("skills/installation.rs"); +include!("skills/retro.rs"); include!("skills/tests.rs"); +include!("skills/retro_tests.rs"); diff --git a/crates/devup-mcp/src/server/skills/catalog.rs b/crates/devup-mcp/src/server/skills/catalog.rs index bdd21a77..1744bda5 100644 --- a/crates/devup-mcp/src/server/skills/catalog.rs +++ b/crates/devup-mcp/src/server/skills/catalog.rs @@ -107,6 +107,10 @@ const EMBEDDED: &[(&str, Documents)] = &[ "devup", &[("SKILL.md", include_str!("devup/SKILL.md"))], ), + ( + RETRO_NAME, + &[("SKILL.md", include_str!(concat!(retro_name!(), "/SKILL.md")))], + ), ( "changepacks", &[("SKILL.md", include_str!("changepacks/SKILL.md"))], diff --git a/crates/devup-mcp/src/server/skills/commands.rs b/crates/devup-mcp/src/server/skills/commands.rs new file mode 100644 index 00000000..f0b918f7 --- /dev/null +++ b/crates/devup-mcp/src/server/skills/commands.rs @@ -0,0 +1,226 @@ +/// The slash commands devup-mcp ships, and where each runtime reads them. +/// +/// An MCP server cannot start a skill or a slash command itself, so the user +/// gets one to type. Claude Code turns every project skill into a slash +/// command of the same name, so a skill installed with the others already is +/// its command there. OpenCode keeps slash commands in their own directory, +/// so it gets a command file as well. Every other runtime is reported +/// unsupported rather than given a path nobody verified. +/// +/// The retrospective command is named here and nowhere else in Rust. Renaming +/// it (to `devup-review-next`, `devup-confirm`, ...) is: change this literal, +/// `git mv skills/devup-retro skills/`, and replace the old name in that +/// skill's frontmatter and its `manifest.json` entry (then update the bytes +/// and sha256). `the_retro_name_is_spelled_in_one_place` fails until all of +/// them agree. +macro_rules! retro_name { + () => { + "devup-retro" + }; +} +pub const RETRO_NAME: &str = retro_name!(); + +pub struct Command { + pub name: &'static str, + pub invocation: &'static str, + /// What typing it does, shown once it is installed. + ready: &'static str, + /// Shown to a runtime with no verified command location; `{}` is its name. + unsupported: &'static str, + /// A hand-written OpenCode command file, or `None` to build one from the + /// skill of the same name so the two cannot drift apart. + opencode: Option<&'static str>, +} + +pub const COMMAND_NAME: &str = "devup"; +pub const COMMAND_INVOCATION: &str = "/devup [figma url] [notes]"; +pub const COMMANDS: &[Command] = &[ + Command { + name: COMMAND_NAME, + invocation: COMMAND_INVOCATION, + ready: "Type /devup with a Figma link and notes to start the whole workflow.", + unsupported: include_str!("../text/skills-command_state-84cd29d8.md"), + opencode: Some(include_str!("../commands/opencode-devup.md")), + }, + Command { + name: RETRO_NAME, + invocation: concat!("/", retro_name!(), " [scope or notes]"), + ready: concat!( + "Type /", + retro_name!(), + " after devup-mcp work is finished: it reviews the session's follow-up prompts, \ + asks you to confirm the list, then prints improvement prompts for devup-mcp." + ), + unsupported: concat!( + "devup-mcp writes no slash command for {}: no command location for it is verified. \ + The ", + retro_name!(), + " skill is installed with the others; ask for it by name, or use the MCP prompt of \ + the same name." + ), + opencode: None, + }, +]; +const OPENCODE_COMMAND_ROOT: &str = ".opencode/command"; +const OPENCODE_USER_COMMAND_ROOT: &str = ".config/opencode/command"; + +impl Command { + pub fn find(name: &str) -> Option<&'static Command> { + COMMANDS.iter().find(|command| command.name == name) + } + + /// Marks a command file devup-mcp wrote, so a later install may rewrite + /// it and leaves anyone else's alone. + fn marker(&self) -> String { + format!("\n\nRun the {} command for: $ARGUMENTS\n\n{}", + self.marker(), + self.name, + skill_body(self.name).trim_start(), + ) + .into() + } + + fn opencode_paths(&self, lookup: &Lookup) -> Vec { + let file = format!("{}.md", self.name); + let mut paths = vec![join_root(&lookup.project, OPENCODE_COMMAND_ROOT).join(&file)]; + if let Some(home) = &lookup.home { + paths.push(join_root(home, OPENCODE_USER_COMMAND_ROOT).join(&file)); + } + paths + } + + /// Whether the command is installed for the detected runtime, and where. + pub fn state(&self, lookup: &Lookup) -> serde_json::Value { + let name = self.name; + let claude = || { + let paths = Lookup { + runtime: Runtime::ClaudeCode, + ..lookup.clone() + } + .installed_paths(name); + serde_json::json!({ + "runtime": "claude-code", + "via": "skill", + "installed": !paths.is_empty(), + "paths": paths.iter().map(|path| path.display().to_string()).collect::>(), + "how": format!("Claude Code offers every project skill as a slash command of the same name; the {name} skill is /{name}."), + }) + }; + let opencode = || { + let found = self + .opencode_paths(lookup) + .into_iter() + .filter(|path| path.is_file()) + .collect::>(); + serde_json::json!({ + "runtime": "opencode", + "via": "command", + "installed": !found.is_empty(), + "paths": found.iter().map(|path| path.display().to_string()).collect::>(), + "writesTo": join_root(&lookup.project, OPENCODE_COMMAND_ROOT).join(format!("{name}.md")).display().to_string(), + "how": "OpenCode reads slash commands from .opencode/command/*.md.", + }) + }; + let per_runtime = match lookup.runtime { + Runtime::ClaudeCode => vec![claude()], + Runtime::Opencode => vec![opencode()], + Runtime::Unknown => vec![claude(), opencode()], + _ => Vec::new(), + }; + let supported = !per_runtime.is_empty(); + let installed = per_runtime.iter().any(|entry| entry["installed"] == true); + serde_json::json!({ + "name": name, + "invocation": self.invocation, + "runtime": lookup.runtime.as_str(), + "supported": supported, + "installed": installed, + "perRuntime": per_runtime, + "how": if !supported { + self.unsupported.replace("{}", lookup.runtime.as_str()) + } else if installed { + self.ready.to_owned() + } else { + format!("Call devup_skills with action \"install\"; it writes /{name} with the skills.") + }, + }) + } +} + +/// A carried skill's entry document without its frontmatter. +pub fn skill_body(name: &str) -> &'static str { + let text = find_by_name(name) + .and_then(|skill| skill.texts) + .and_then(|documents| documents.iter().find(|(path, _)| *path == ENTRY_DOCUMENT)) + .map_or("", |(_, text)| *text); + &text[frontmatter_end(text).unwrap_or(0)..] +} + +/// Whether an install writes the OpenCode commands: when OpenCode is the +/// client, or when the skills go to its skill root. `roots` are the install's +/// roots as chosen before anything was staged - staging creates directories, +/// and asking again afterwards sees only the first of them. +fn writes_opencode_command(lookup: &Lookup, roots: &[PathBuf]) -> bool { + lookup.runtime == Runtime::Opencode + || roots + .iter() + .any(|root| root.ends_with(Path::new(".opencode").join("skill"))) +} + +/// Whether `/devup` is installed for the detected runtime, and where. +pub fn command_state(lookup: &Lookup) -> serde_json::Value { + COMMANDS[0].state(lookup) +} + +/// Stages the OpenCode command files when this install writes them. A file +/// devup-mcp did not write is left alone and reported. +fn stage_commands( + policy: &super::output::OutputPolicy, + transaction: &mut super::output::OutputTransaction, + lookup: &Lookup, + roots: &[PathBuf], +) -> Result, devup_mcp_figma::DevupError> { + if !writes_opencode_command(lookup, roots) { + return Ok(Vec::new()); + } + let mut reports = Vec::new(); + for command in COMMANDS { + let path = + join_root(&lookup.project, OPENCODE_COMMAND_ROOT).join(format!("{}.md", command.name)); + let text = command.opencode_text(); + let existing = std::fs::read_to_string(&path).ok(); + let state = match existing.as_deref() { + Some(found) if found == text => "alreadyPresent", + Some(found) if !found.contains(&command.marker()) => "leftAlone", + Some(_) => "refreshed", + None => "installed", + }; + if matches!(state, "installed" | "refreshed") { + let target = policy.resolve(&path.display().to_string())?; + transaction.stage( + format!("command:opencode:{}", command.name), + target, + text.as_bytes(), + )?; + } + reports.push(serde_json::json!({ + "name": command.name, + "runtime": "opencode", + "path": path.display().to_string(), + "state": state, + "invocation": command.invocation, + })); + } + Ok(reports) +} diff --git a/crates/devup-mcp/src/server/skills/devup-retro/SKILL.md b/crates/devup-mcp/src/server/skills/devup-retro/SKILL.md new file mode 100644 index 00000000..0fa0ecf1 --- /dev/null +++ b/crates/devup-mcp/src/server/skills/devup-retro/SKILL.md @@ -0,0 +1,177 @@ +--- +name: devup-retro +description: | + Session retrospective for work done with devup-mcp. Reviews the conversation + from the first devup-mcp call to now, lists every follow-up prompt the user had + to write to correct or complete the result, classifies each by the devup-mcp + surface that should have prevented it, asks the user to confirm the list, and + only then prints ready-to-paste improvement prompts for devup-mcp development. + + Invoke as /devup-retro [scope or notes] once a piece of devup-mcp work is + finished. Also use when asked to "turn this session's corrections into + devup-mcp feedback" or to explain why follow-up prompts were needed. +--- + +# Session retrospective: follow-up prompts to improvement prompts + +The goal is that the user never has to write the same follow-up prompt again. +Every correction the user typed after devup-mcp produced something is evidence +of a gap. This command turns that evidence into prompts the devup-mcp +maintainers can act on. It reviews; it does not fix the application and it does +not change devup-mcp. + +Three phases, in this order, never merged: **review, confirm, prompts**. +Nothing is written to disk and no improvement prompt is printed before the user +confirms the review list. + +## Phase 1: review + +1. Call `devup_skills` with `action: "retro"` and the same `projectRoot` the + session used. It returns the installed build (`server.version`, + `server.commit`, `server.buildId`), this server process's devup-mcp call log + with timestamps (`callLog`), the `.feedback/` folder state (`feedback`), the + surface list (`surfaces`) and where release notes live (`releases`). + The call log only covers this server process: after a reconnect or in a + resumed session it is partial, and `callLog.note` says so. It is an aid for + ordering, never the evidence itself. +2. Find the starting point: the first devup-mcp call of the work under review + (export, plan, validation, audit, compare). An argument given to the command + narrows the scope ("only the landing page", "since the second export"). If + the conversation was compacted and the early part is no longer visible, say + which part you could not read. Do not reconstruct it from memory. +3. Walk every user message after that point. Keep a message when the user had + to correct, redo or complete something devup-mcp's output, validation or + guidance was responsible for, for example: + - missing or wrong motion ("add the animation", "the reveal replays"); + - wrong layout (spacing, width, alignment, overflow, a width that was never audited); + - wrong text wrapping, including Korean word breaks; + - missing interaction states, links or events left unwired; + - wrong, missing or placeholder assets; + - tests, lint, typecheck or audits not run, or reported as run without evidence; + - wrong CI or completion evidence (wrong commit, wrong run, counts that do not match); + - a question asked late, or asked again, that intake should have settled. + Drop messages that are new requirements, changes of mind, approvals, or + ordinary conversation. A message is a follow-up only if the earlier result + should already have satisfied it. +4. For each kept message, record one item: + - `quote`: the user's words, verbatim and in their language, trimmed to the relevant sentence; + - `what`: what was wrong, in one sentence, stated as an observation; + - `evidence`: the file, Figma node id, tool call or tool output that shows it + (a `callLog` entry, a validation result, an audit report, a diff). Cite what + you actually read in this session; + - `surface`: exactly one value from `surfaces` (below); + - `cause`: observed fact versus your inference, labelled separately; + - `status`: see "Already fixed" below. + Merge messages about the same defect into one item and keep every quote. + +### Rules that keep the list honest + +- No invented problems. An item with no user-prompt evidence is excluded, even + if you noticed the defect yourself. Mention such observations separately, + after the list, as "not from a follow-up prompt"; they never become prompts. +- Not every problem is a devup-mcp bug. Classify agent mistakes, Devup UI + compiler behaviour, application code and Figma data as what they are. +- Do not strengthen the user's words. "UI가 잘못됐어" stays that quote; the + specific defect comes from the evidence, not from the quote. +- When the evidence is missing, the item says "evidence not found in this + session" and is listed as unverified rather than dropped silently. + +### Surfaces + +| surface | The gap is in | +| --- | --- | +| `codegen` | the TSX/CSS the converter generates from Figma nodes | +| `export-output` | what `devup_figma_export` returns: outputs, assets, manifests, deliverable flags, sidecars | +| `validation-rule` | a `devup_ui_validate` rule that is missing, too weak or wrong | +| `runtime-audit` | `devup_runtime_audit`, audit plans, `devup_visual_compare` checks and their evidence | +| `skill-guidance` | the installed skills (`devup`, `devup-implementation-plan`, `devfive-frontend` and references) | +| `bootstrap-instructions` | the server instructions and `devup://guide/usage` | +| `project-plan-intake` | `devup_project_plan` / `devup_screen_plan` questions, rounds, ERD, split | +| `out-of-scope:devup-ui` | Devup UI runtime or compiler, not devup-mcp | +| `out-of-scope:app` | the application's own code, tests, CI or environment | +| `out-of-scope:figma-data` | the design file itself (missing variants, wrong constraints) | + +Pick the surface that should have **prevented** the follow-up, which is not +always the one that produced the defect: a converter gap that validation would +have caught is `codegen`, and the acceptance check in its prompt names the +validation rule. + +### Already fixed + +Compare each item with the installed build from `devup_skills retro`, the +release notes at `releases.url` (use `gh release list -R dev-five-git/devup-mcp` +or a fetch tool when one is available), and any status line in the project's +`.feedback/` records. Set `status` to one of: + +- `open`: no evidence of a fix; +- `fixed-in-installed`: the installed version already contains the fix; the + follow-up was caused by something else (a stale skill copy, an agent mistake) + and the item says which; +- `fixed-not-installed`: a newer release fixes it; `server.updateAvailable` or + the release notes show it; the action is to update, not to file a prompt; +- `already-recorded`: an existing `.feedback/` record covers it; name the file; +- `unverified`: release notes could not be read. Say so; never guess "fixed". + +## Phase 2: confirm + +Show the review list as a table, numbered, in the user's language: quote, +what was wrong, surface, evidence, status. Then ask with the runtime question +tool (Claude Code `AskUserQuestion`, OpenCode `question`), in one call: + +1. Review list: `OK` (Recommended) / `Edit items` / `Drop items` / `Cancel`. +2. If a `.feedback/` folder exists (`feedback.exists`): also save each item as a + feedback record? `Yes` / `No`. Without the folder, do not offer to create one. + +On `Edit items` or `Drop items`, apply what the user says, show the updated +table and ask again. Repeat until `OK` or `Cancel`. On `Cancel`, stop: print +nothing further and write nothing. Without a question tool, print the table, +ask the same question in one message and wait; never assume `OK`. + +## Phase 3: prompts + +Only after `OK`. Write in the user's language (Korean when the session is +Korean); keep identifiers, tool names, file paths and code in their original +form. Group items by surface and print one ready-to-paste prompt per surface, +each in its own fenced block so it can be copied whole. + +Items with status `fixed-in-installed`, `fixed-not-installed` or +`already-recorded` get no prompt. List them under the prompts with the action +that applies (update devup-mcp, reinstall skills, see the existing record). +`out-of-scope:*` items get a short prompt addressed to that owner (Devup UI, +the application, the designer), clearly marked as not for devup-mcp. + +Every prompt has these four parts, in this order: + +1. **Goal**: one sentence, addressed to devup-mcp development, naming the + surface and the outcome ("devup-mcp의 UI 구현·검증 흐름에 … 를 기본 품질 + 항목으로 추가해 주세요."). +2. **Required behaviour**: a numbered list of concrete, checkable behaviours. + Each one traces to an item of the confirmed list; quote the user prompt and + cite the evidence (file, node id, tool output) once, under the list. +3. **Acceptance checks and regression tests**: what must pass, including at + least one regression test that fails on today's behaviour, and which tool + output proves it (`devup_ui_validate` finding id, audit check, snapshot). +4. **Scope boundary**: what this prompt does not ask for, and which neighbouring + surface owns the rest. + +State the installed build once above the prompts (`version`, `commit`, +`buildId`) so the maintainers know what the session ran against. Do not pad: +a prompt with one item has a one-item list. Do not propose a fix the evidence +does not support, and do not prescribe implementation details beyond what the +required behaviour needs. + +### Saving feedback records (only when the user chose Yes) + +Read the folder first: its index file (`feedback.index`), and the two most +recent records, and follow their format exactly, including language, field +labels, file extension and file-name pattern. Number new records from +`feedback.nextNumber`, one file per confirmed item, and add one line per record +to the index in its existing style. Never renumber, rewrite or delete existing +records, and never record an item the user dropped. Report the files written. +If the folder's format cannot be determined, do not write; say why. + +## Final answer + +End with: the number of user follow-ups reviewed, items confirmed, prompts +printed per surface, items needing no prompt and why, files written (or +"nothing written"), and anything you could not review. diff --git a/crates/devup-mcp/src/server/skills/installation.rs b/crates/devup-mcp/src/server/skills/installation.rs index 846a51c8..2f7ce5c7 100644 --- a/crates/devup-mcp/src/server/skills/installation.rs +++ b/crates/devup-mcp/src/server/skills/installation.rs @@ -349,137 +349,6 @@ impl Skill { } } -/// The explicit entry point: `/devup [figma url] [notes]`. -/// -/// An MCP server cannot start a skill or a slash command itself, so the user -/// gets one to type. Claude Code turns every project skill into a slash -/// command of the same name, so the `devup` skill installed with the others -/// already is `/devup` there. OpenCode keeps slash commands in their own -/// directory, so it gets a command file as well. Every other runtime is -/// reported unsupported rather than given a path nobody verified. -pub const COMMAND_NAME: &str = "devup"; -pub const COMMAND_INVOCATION: &str = "/devup [figma url] [notes]"; -/// Marks a command file devup-mcp wrote, so a later install may rewrite it -/// and leaves anyone else's alone. -const COMMAND_MARKER: &str = "