diff --git a/.changepacks/changepack_log_proposal_confirm.json b/.changepacks/changepack_log_proposal_confirm.json new file mode 100644 index 00000000..93404eac --- /dev/null +++ b/.changepacks/changepack_log_proposal_confirm.json @@ -0,0 +1,7 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Patch" + }, + "note": "Accept tool arguments a client sent as strings where the schema wants a number, boolean, array or object. devup_screen_plan draft now returns `proposal` and `proposalMarkdown`, a single table of every open decision with the recommended answer, so the user confirms the whole plan in one question asked in their own language instead of answering decision by decision. confirm takes `acceptRecommended: true`: any applicable question left out of `answers` gets its recommended option. When confirm would put two screens on one URL it writes nothing and returns `details.conflict` and `details.retryAnswers`, which suggests a different URL to retry with.", + "date": "2026-10-19T00:00:00+09:00" +} diff --git a/crates/devup-mcp/src/server/coerce_args.rs b/crates/devup-mcp/src/server/coerce_args.rs new file mode 100644 index 00000000..02162a57 --- /dev/null +++ b/crates/devup-mcp/src/server/coerce_args.rs @@ -0,0 +1,153 @@ +use serde_json::Value; + +#[cfg(test)] +use serde_json::json; + +/// Declared JSON types of a property schema, following `$ref`, `anyOf`, +/// `oneOf` and `allOf` into the root `$defs`. Depth-limited so a cyclic +/// schema cannot loop. +fn declared_types( + root: &serde_json::Map, + schema: &Value, + depth: u8, + out: &mut Vec, +) { + if depth > 6 { + return; + } + let Some(object) = schema.as_object() else { + return; + }; + match object.get("type") { + Some(Value::String(kind)) => out.push(kind.clone()), + Some(Value::Array(kinds)) => { + out.extend(kinds.iter().filter_map(|k| k.as_str().map(str::to_owned))) + } + _ => {} + } + if !out.iter().any(|t| t == "string") + && object + .get("enum") + .and_then(Value::as_array) + .is_some_and(|values| values.iter().all(Value::is_string)) + { + out.push("string".to_owned()); + } + if let Some(reference) = object.get("$ref").and_then(Value::as_str) + && let Some(name) = reference.rsplit('/').next() + && let Some(target) = ["$defs", "definitions"] + .iter() + .find_map(|key| root.get(*key).and_then(|defs| defs.get(name))) + { + declared_types(root, target, depth + 1, out); + } + for key in ["anyOf", "oneOf", "allOf"] { + if let Some(Value::Array(branches)) = object.get(key) { + for branch in branches { + declared_types(root, branch, depth + 1, out); + } + } + } +} + +/// Some MCP clients send a structured argument as its JSON text (`"[\"tsx\"]"` +/// for an array, `"true"` for a boolean, `"60000"` for a number). The schema +/// says what the field is, so a string that parses as exactly that is +/// accepted instead of failing the first call. A field that is declared to +/// accept a string is never touched. +pub(super) fn coerce_stringified_arguments( + schema: &serde_json::Map, + arguments: &mut serde_json::Map, +) { + let Some(Value::Object(properties)) = schema.get("properties") else { + return; + }; + for (key, value) in arguments.iter_mut() { + let Value::String(text) = value else { + continue; + }; + let Some(property) = properties.get(key) else { + continue; + }; + let mut types = Vec::new(); + declared_types(schema, property, 0, &mut types); + if types.is_empty() || types.iter().any(|t| t == "string") { + continue; + } + let Ok(parsed) = serde_json::from_str::(text.trim()) else { + continue; + }; + let accepted = types.iter().any(|kind| match kind.as_str() { + "array" => parsed.is_array(), + "object" => parsed.is_object(), + "boolean" => parsed.is_boolean(), + "integer" => parsed.is_i64() || parsed.is_u64(), + "number" => parsed.is_number(), + "null" => parsed.is_null(), + _ => false, + }); + if accepted { + *value = parsed; + } + } +} + +#[cfg(test)] +mod coerce_tests { + use super::*; + + fn schema() -> serde_json::Map { + json!({ + "type": "object", + "properties": { + "outputs": {"type": "array", "items": {"type": "string"}}, + "refresh": {"type": "boolean"}, + "limit": {"type": ["integer", "null"]}, + "url": {"type": ["string", "null"]}, + "mode": {"anyOf": [{"$ref": "#/$defs/Mode"}, {"type": "null"}]}, + "plan": {"anyOf": [{"$ref": "#/$defs/Plan"}, {"type": "null"}]} + }, + "$defs": { + "Mode": {"type": "string", "enum": ["a", "b"]}, + "Plan": {"type": "object"} + } + }) + .as_object() + .unwrap() + .clone() + } + + fn run(args: Value) -> Value { + let mut args = args.as_object().unwrap().clone(); + coerce_stringified_arguments(&schema(), &mut args); + Value::Object(args) + } + + #[test] + fn stringified_structured_values_are_decoded() { + let out = run(json!({ + "outputs": "[\"tsx\"]", + "refresh": "true", + "limit": "20", + "plan": "{\"a\":1}" + })); + assert_eq!(out["outputs"], json!(["tsx"])); + assert_eq!(out["refresh"], json!(true)); + assert_eq!(out["limit"], json!(20)); + assert_eq!(out["plan"], json!({"a": 1})); + } + + #[test] + fn string_fields_and_mismatches_are_left_alone() { + let out = run(json!({ + "url": "[\"x\"]", + "mode": "[1]", + "outputs": "tsx", + "limit": "abc" + })); + assert_eq!(out["url"], json!("[\"x\"]")); + assert_eq!(out["mode"], json!("[1]")); + assert_eq!(out["outputs"], json!("tsx")); + assert_eq!(out["limit"], json!("abc")); + } +} 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 010bc870..efd56b3a 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 @@ -64,6 +64,8 @@ fn apply_explore_limit(mut response: Value, limit: usize) -> Value { response } +#[path = "../coerce_args.rs"] +mod coerce_args; #[path = "../handlers/devup_figma_measure.rs"] mod devup_figma_measure_handler; #[path = "../handlers/devup_skills.rs"] @@ -735,6 +737,12 @@ impl ServerHandler for DevupServer { { let _ = self.client_name.set(peer.client_info.name.clone()); } + let mut request = request; + if let (Some(tool), Some(arguments)) = + (self.tool_router.get(request.name.as_ref()), request.arguments.as_mut()) + { + coerce_args::coerce_stringified_arguments(&tool.input_schema, arguments); + } let call = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); let mut response = match self.tool_router.call(call).await { Ok(response) => response, @@ -958,4 +966,4 @@ mod r8_recovery_tests { } } } -} \ No newline at end of file +} diff --git a/crates/devup-mcp/src/server/project_plan/mod.rs b/crates/devup-mcp/src/server/project_plan/mod.rs index 4b6daf6c..3300f3e4 100644 --- a/crates/devup-mcp/src/server/project_plan/mod.rs +++ b/crates/devup-mcp/src/server/project_plan/mod.rs @@ -35,6 +35,10 @@ pub struct ProjectPlanInput { #[serde(default)] #[schemars(extend("additionalProperties" = json!({})))] pub answers: BTreeMap, + /// draft/confirm: the user accepted the proposal table; every question + /// missing from `answers` takes its recommended option. + #[serde(default)] + pub accept_recommended: bool, #[serde(default)] pub features: Vec, /// Explicit ERD models in vespertide wire format; confirm may refine the draft. @@ -174,7 +178,10 @@ fn response(plan: Value) -> Value { .filter(|q| plan["answers"][text(&q["id"])].is_null()) .cloned() .collect::>(); + let proposal = screen_plan::proposal(&questions); json!({"planId":plan["planId"], "state":plan["state"], + "proposalMarkdown":screen_plan::proposal_markdown(&proposal), + "proposal":proposal, "questions":screen_plan::ask_shape(&questions), "questionRounds":screen_plan::question_rounds(&questions, 4), "instruction": if plan["state"] == "confirmed" { include_str!("../text/mod-response-22d8f704.md") } else { screen_plan::ASK_INSTRUCTION }, @@ -221,6 +228,9 @@ pub(super) async fn run( return Err(invalid("Confirmed plans cannot be redrafted")); } rounds::answers(&mut plan, &input.answers)?; + if input.accept_recommended { + rounds::accept_recommended(&mut plan)?; + } rounds::require_answers(&plan)?; for ev in &input.evidence { if ev.source.trim().is_empty() { @@ -259,6 +269,9 @@ pub(super) async fn run( return Err(invalid("Draft the complete plan before confirm")); } rounds::answers(&mut plan, &input.answers)?; + if input.accept_recommended { + rounds::accept_recommended(&mut plan)?; + } rounds::require_answers(&plan)?; if plan["answers"] .as_object() diff --git a/crates/devup-mcp/src/server/project_plan/rounds.rs b/crates/devup-mcp/src/server/project_plan/rounds.rs index 21f96c0b..d417380e 100644 --- a/crates/devup-mcp/src/server/project_plan/rounds.rs +++ b/crates/devup-mcp/src/server/project_plan/rounds.rs @@ -75,6 +75,18 @@ pub(super) fn answers( } Ok(()) } +pub(super) fn accept_recommended(plan: &mut Value) -> Result<(), DevupError> { + let fill = array(&plan["questions"]) + .iter() + .filter(|q| plan["answers"][text(&q["id"])].is_null()) + .filter_map(|q| { + array(&q["resolve"]) + .first() + .map(|r| (text(&q["id"]).to_owned(), r["label"].clone())) + }) + .collect::>(); + answers(plan, &fill) +} pub(super) fn require_answers(plan: &Value) -> Result<(), DevupError> { let missing = array(&plan["questions"]) .iter() diff --git a/crates/devup-mcp/src/server/project_plan/tests.rs b/crates/devup-mcp/src/server/project_plan/tests.rs index 437b5419..cfbf45c6 100644 --- a/crates/devup-mcp/src/server/project_plan/tests.rs +++ b/crates/devup-mcp/src/server/project_plan/tests.rs @@ -122,6 +122,19 @@ async fn figma_erd_confirm_writes_schema_valid_models() { } #[tokio::test] +async fn accepting_the_proposal_confirms_without_answers() { + let dir = TestDir::new(); + let (mut i, policy, draft) = drafted(dir.path(), true).await; + assert_eq!( + array(&draft["proposal"]).len(), + array(&draft["plan"]["questions"]).len() + ); + i.answers.clear(); + i.accept_recommended = true; + let r = run(&i, &policy).await.unwrap(); + assert_eq!(r["state"], "confirmed"); +} +#[tokio::test] async fn many_to_many_join_table_and_gapped_roles_validate() { let dir = TestDir::new(); let (mut i, policy, r) = drafted(dir.path(), true).await; diff --git a/crates/devup-mcp/src/server/screen_plan/actions.rs b/crates/devup-mcp/src/server/screen_plan/actions.rs index 5a65548a..aa39e03c 100644 --- a/crates/devup-mcp/src/server/screen_plan/actions.rs +++ b/crates/devup-mcp/src/server/screen_plan/actions.rs @@ -41,7 +41,13 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< continue; } let Some(answer) = input.answers.get(&id) else { - missing.push(id); + if input.accept_recommended + && let Some(recommended) = array(&question["resolve"]).first() + { + actions.insert(id, recommended["action"].clone()); + } else { + missing.push(id); + } continue; }; let (action, note) = resolve_answer(question, answer)?; @@ -265,10 +271,7 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< ) && other != text(&row["screen"]) { - return Err(invalid(format!( - "Two screens resolve to {url} ({other} and {}); answer their route questions with different URLs.", - text(&row["screen"]) - ))); + return Err(duplicate_route(&plan, url, &other, &row["screen"], &row["name"])); } } for row in routes @@ -552,6 +555,74 @@ pub(super) async fn audit_plan( pub(super) const DONE_RULE: &str = include_str!("../text/screen_plan-DONE_RULE-95793a55.md"); +/// The question ids that place `screen` (a screen id, or a frame node id for +/// a frame split into its own page). +fn placing_questions(plan: &Value, screen: &str) -> Vec { + array(&plan["questions"]) + .iter() + .map(|question| text(&question["id"])) + .filter(|id| { + ["route:", "review:", "modal:", "group:", "split-route:", "collision:"] + .iter() + .any(|prefix| id.strip_prefix(prefix) == Some(screen)) + }) + .map(str::to_owned) + .collect() +} + +fn suggested_route(name: &str, screen: &str, taken: &str) -> String { + let words = name + .to_lowercase() + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_owned) + .collect::>(); + let base = if words.is_empty() { + format!("/page-{}", screen.replace([':', ';'], "-")) + } else { + format!("/{}", words.join("-")) + }; + if base == taken { + format!("{base}-{}", screen.replace([':', ';'], "-")) + } else { + base + } +} + +/// Two screens on one URL: the error names both screens, the questions that +/// place each, and a distinct URL for the second, so the caller can retry +/// confirm with one changed answer instead of guessing. +fn duplicate_route(plan: &Value, url: &str, first: &str, second: &Value, name: &Value) -> DevupError { + let second = text(second); + let first_name = array(&plan["screens"]) + .iter() + .find(|screen| screen["id"] == first) + .map(|screen| screen["name"].clone()) + .unwrap_or(Value::Null); + let suggestion = suggested_route(text(name), second, url); + let questions = placing_questions(plan, second); + let retry = questions + .iter() + .find(|id| !id.starts_with("collision:")) + .map(|id| json!({id.as_str(): suggestion})) + .unwrap_or(json!({})); + DevupError::with_details( + ErrorCode::DevupInvalidInput, + format!( + "Two screens resolve to {url} ({first} and {second}). Nothing was written. Retry confirm with the same answers plus {retry}, or show the user both screens and ask which one owns {url}." + ), + false, + json!({ + "url": url, + "conflict": [ + {"screen": first, "name": first_name, "questions": placing_questions(plan, first)}, + {"screen": second, "name": name, "questions": questions, "suggestedUrl": suggestion}, + ], + "retryAnswers": retry, + }), + ) +} + fn screen_table(plan: &Value) -> Vec { array(&plan["screens"]) .iter() diff --git a/crates/devup-mcp/src/server/screen_plan/draft.rs b/crates/devup-mcp/src/server/screen_plan/draft.rs index 5629a7c3..dc87990c 100644 --- a/crates/devup-mcp/src/server/screen_plan/draft.rs +++ b/crates/devup-mcp/src/server/screen_plan/draft.rs @@ -311,6 +311,58 @@ pub(super) fn question_rounds(questions: &[Value], per_round: usize) -> Vec Vec { + ask_shape(questions) + .iter() + .map(|question| { + let labels = array(&question["options"]) + .iter() + .map(|option| strip_recommended(text(&option["label"])).to_owned()) + .collect::>(); + json!({ + "id": question["id"], + "topic": question["header"], + "decision": question["question"], + "recommended": labels.first(), + "alternatives": labels.get(1..).unwrap_or_default(), + "when": question["when"], + }) + }) + .collect() +} + +pub(crate) fn proposal_markdown(rows: &[Value]) -> String { + let cell = |value: &Value| match value { + Value::Null => "-".to_owned(), + Value::String(s) => s.clone(), + Value::Array(items) if items.is_empty() => "-".to_owned(), + Value::Array(items) => items + .iter() + .map(|item| item.as_str().map_or_else(|| item.to_string(), str::to_owned)) + .collect::>() + .join(" / "), + other => other.to_string(), + } + .replace('|', "\\|") + .replace('\n', " "); + let mut out = String::from( + "| # | topic | decision | recommended | alternatives |\n|---|---|---|---|---|\n", + ); + for (index, row) in rows.iter().enumerate() { + out.push_str(&format!( + "| {} | {} | {} | {} | {} |\n", + index + 1, + cell(&row["topic"]), + cell(&row["decision"]), + cell(&row["recommended"]), + cell(&row["alternatives"]), + )); + } + out +} + fn plan_path(project_root: &str, relative: &str) -> String { Path::new(project_root) .join(relative) @@ -429,9 +481,12 @@ pub(super) async fn draft_with( .iter() .filter(|screen| screen["routeEvidence"] == "UNVERIFIED") .count(); + let proposal_rows = proposal(array(&plan["questions"])); Ok(json!({ "status": "draft", "planId": plan["planId"], + "proposal": proposal_rows, + "proposalMarkdown": proposal_markdown(&proposal_rows), "screens": screen_table(&plan), "questionRounds": rounds, "questions": questions, @@ -452,7 +507,7 @@ pub(super) async fn draft_with( json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": {}}, "how": "Nothing is open. Show the user the screens table, then confirm with empty answers."}) } else { - json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": " chosen label, for every question that applies>"}, + json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "acceptRecommended": true, "answers": " chosen label or custom text>"}, "how": ASK_INSTRUCTION}) }, })) diff --git a/crates/devup-mcp/src/server/screen_plan/state.rs b/crates/devup-mcp/src/server/screen_plan/state.rs index 1151fc0d..d8f9a2e1 100644 --- a/crates/devup-mcp/src/server/screen_plan/state.rs +++ b/crates/devup-mcp/src/server/screen_plan/state.rs @@ -102,6 +102,11 @@ pub struct ScreenPlanInput { /// multiple), or custom text. #[serde(default)] pub answers: BTreeMap, + /// confirm: the user accepted the proposal table. Every applicable + /// question missing from `answers` takes its recommended option, so only + /// the rows the user changed need to be sent. + #[serde(default)] + pub accept_recommended: bool, /// status: the runtime audit plan JSON, together with auditReportPath. #[serde(default)] pub audit_plan_path: Option, diff --git a/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs b/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs index a08c111d..45bdbc1e 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs @@ -867,4 +867,62 @@ async fn reviewing_heuristics_asks_each_one_in_the_same_batch() { .insert("review:12:1".into(), AnswerInput::One("/home".into())); let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); assert_eq!(confirmed["routes"][0]["url"], "/home"); +} + +#[tokio::test] +async fn accepting_the_proposal_confirms_with_no_answers() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.accept_recommended = true; + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert_eq!(confirmed["routes"][0]["url"], "/"); + assert_eq!(confirmed["routes"][0]["nodeIds"], json!(["12:1", "12:2"])); +} + +#[tokio::test] +async fn two_screens_on_one_url_return_a_retry_that_confirms() { + let fixture = Fixture::new(); + let mut confirm_input = drafted( + &fixture, + &[ + screen("1:2", "Pricing", 1440.0), + screen("1:3", "Plans", 1440.0), + ], + ) + .await; + confirm_input.accept_recommended = true; + confirm_input.answers = answers(&[("route:1:2", "/pricing"), ("route:1:3", "/pricing")]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert_eq!(error.details["url"], "/pricing"); + assert_eq!(array(&error.details["conflict"]).len(), 2); + let retry = error.details["retryAnswers"].as_object().unwrap(); + assert_eq!(retry.len(), 1); + let (id, url) = retry.iter().next().unwrap(); + assert!(id.starts_with("route:")); + assert_ne!(url, "/pricing"); + confirm_input + .answers + .insert(id.clone(), AnswerInput::One(text(url).to_owned())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert_eq!(array(&confirmed["routes"]).len(), 2); +} + +#[tokio::test] +async fn draft_returns_one_proposal_row_per_question() { + let fixture = Fixture::new(); + let drafted = draft( + &input(&fixture, "draft"), + &main_widths(), + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let rows = array(&drafted["proposal"]); + assert_eq!(rows.len(), array(&drafted["questions"]).len()); + assert!(rows.iter().all(|row| row["recommended"].is_string())); + assert!( + text(&drafted["proposalMarkdown"]).starts_with("| # | topic | decision | recommended") + ); + assert_eq!(drafted["nextAction"]["arguments"]["acceptRecommended"], true); } \ No newline at end of file diff --git a/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md index b4597c39..cdc04d73 100644 --- a/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md @@ -124,13 +124,17 @@ So the order is fixed, and none of it is optional. - **No implementation before confirm.** No page, layout or component file is written until `devup_screen_plan` `confirm` has succeeded. -- **All questions up front, back to back.** Ask every question before any - implementation starts. If your question tool has no per-call cap (OpenCode - `question`), ask the flat `questions` list in one call; each conditional - question says when it applies. If it has one (Claude Code AskUserQuestion: - 4), ask `questionRounds` in consecutive turns with no other work in between; - pass `maxQuestionsPerRound` to draft when your cap is different. Never one - decision at a time spread through the work. +- **One table, one confirmation.** Do not interview the user decision by + decision. Draft returns `proposal` / `proposalMarkdown`: every open decision + as a row with the recommended answer and the alternatives. Show that table + and ask ONE question with your question tool: accept it as it is, or name + the rows to change. Then confirm with `acceptRecommended: true` and only the + changed rows in `answers`. Use `questions` / `questionRounds` only to review + the rows the user asked to change. +- **Ask in the user's language.** Translate the table, the question and the + option labels into the language the user first wrote to you in, for display + only. Answers sent to confirm stay keyed by question id and use the original + option labels (or custom text such as a route). - **Ask what the whole job needs, now.** Every question you would need at any point - screens and routes, site files and facts, and your own (`extraQuestions`) - goes into the up-front rounds. Then finish the site diff --git a/crates/devup-mcp/src/server/skills/devup/SKILL.md b/crates/devup-mcp/src/server/skills/devup/SKILL.md index 401b3a63..a0995720 100644 --- a/crates/devup-mcp/src/server/skills/devup/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup/SKILL.md @@ -127,11 +127,14 @@ the full rules; these are the steps. open question - routes, sections and structure, the site questions (favicon, share image, site name and URL, analytics, scroll reveal) and yours. -5. **Ask everything now, with your question tool.** The flat `questions` list - in one call if your tool has no per-call cap; otherwise `questionRounds` - back to back. Never in prose, never one decision at a time, never later. -6. **Confirm.** `devup_screen_plan` with `action: "confirm"`, the `planId` and - every answer that applies. +5. **Show the proposal table, ask once.** Show draft's `proposalMarkdown` + (every open decision with its recommended answer) in the language the user + first wrote in, and ask ONE question with your question tool: accept, or + name the rows to change. Never one decision at a time, never later. +6. **Confirm.** `devup_screen_plan` with `action: "confirm"`, the `planId`, + `acceptRecommended: true` and only the changed rows in `answers`. If + confirm reports two screens on one URL, retry with its + `details.retryAnswers` merged into your answers. 7. **Latest first, before implementing, without asking.** a. At the repository root, run `bunx @dependency-check-updates/cli -d -u --rm`. It updates every manifest in the repository to the latest diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index 512359dc..dbe2dfab 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -71,8 +71,8 @@ "documents": [ { "path": "SKILL.md", - "bytes": 35206, - "sha256": "a60e55d51cc97c563419dcc50b449c8d83c2a8324470cfc6ade304a84b85f541" + "bytes": 35488, + "sha256": "9b41c4e4cd15e00509df6d3348efdf5f9d8f756c9185b38679cfbc9e87716793" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup-implementation-plan", @@ -89,8 +89,8 @@ "documents": [ { "path": "SKILL.md", - "bytes": 13426, - "sha256": "e3311f246745b4841d5e4b069d2c67513ab5576b1a783c0b88b020a55b622fb2" + "bytes": 13647, + "sha256": "ac9bb6b339019249911bbcbcb956809dddf8daabbc08c8f7dfa5a64dc310dbb7" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup", diff --git a/crates/devup-mcp/src/server/text/devup-screen-plan.md b/crates/devup-mcp/src/server/text/devup-screen-plan.md index 549550c1..ad1bfba5 100644 --- a/crates/devup-mcp/src/server/text/devup-screen-plan.md +++ b/crates/devup-mcp/src/server/text/devup-screen-plan.md @@ -1 +1 @@ -Plan which Figma screen becomes which URL before any page is written, confirm it up front, then report it URL by width (action: draft | confirm | status | assume | auditPlan | defer). draft reads the linked Section or frame (or the Bridge selection, or `screens` from an earlier devup_figma_explore) and proposes a route only from evidence: an existing page built from the frame, a nav link label bound to an existing href, a navigationTargets entry, or .devup/screen-plan.config.json (modalKeywords, routeAliases, widthGrouping). A route inferred from a layer name is routeEvidence name-match and is put to the user; with no evidence it is UNVERIFIED and asked. Only frames under the link are candidates, never the rest of the file; frames in trash, archive, draft, copy or hidden areas are excluded and listed (excludeKeywords/includeKeywords in the config). Frames sharing a name in one section are asked about per group; the same name in different sections, a screen split across sections, a frame outside any section and duplicates each get an explicit question with a "the Figma structure is wrong" option whose frames are reported, never built. It writes .devup/screen-plan.json and .md and returns `questionRounds`: questions already shaped for your question tool (id, header <= 30 chars, question, options[{label, description}] recommended first, multiple, custom answers allowed), at most maxQuestionsPerRound (default 4) per round. Ask everything now: a question tool with no cap (`question` in OpenCode) takes the flat `questions` list in one call; one with a cap (`AskUserQuestion` in Claude Code) takes the rounds in consecutive calls with no other work in between, skipping a question whose `when` was not met; never ask in prose and never implement before confirm. confirm takes every applicable answer keyed by question id and refuses the call, naming the missing ids. After confirm, never ask the user: take the recommended or most conservative option and record it with assume. status compares the confirmed plan with the routes on disk and an optional devup_runtime_audit plan/report: per URL, implemented yes/no and per-width pass, fail with reason, or not-run - a width without an audit item is never pass - plus every assumption. draft also lists the stack defaults - Next App Router on vinext, a new project from dev-five-git/service-template, and the dependency update `bunx @dependency-check-updates/cli -d -u --rm` before implementing - and asks once whether a next-CLI app migrates to vinext; with several workspace apps it picks the app by meaning (the public app unless the frames clearly say admin, per screen in a mixed file) and asks only when no app looks like the public site; then the site defaults it fills in without asking (an interaction recipe per element role, restrained; one shared transition; reduced motion; view transitions always on (React's ViewTransition; skipped only when the project config opts out), verified by a runtime-audit view-transition check; metadata, lang, alt text, not-found) and asks only what needs a file or a fact: favicon and share image (from existing files or Figma logo/og nodes, never generated), site name and URL when the project does not say, analytics when the project shows it, and scroll reveal; `extraQuestions` adds the agent's own questions to the same rounds so nothing is asked after confirm; .devup/screen-plan.config.json may pre-answer them (favicon, ogImage, siteName, siteUrl, motion). status adds site completeness: favicon, metadata per URL, share image, hover/focus coverage and reduced motion as filled, asked-and-answered or missing. auditPlan writes one devup_runtime_audit plan per confirmed URL from the export artifacts: page health, overflow and clipped text at every width; the page, each section and each asset against its Figma render (devup_visual_compare calls returned by status) and the size and position of each section, card and asset at the drawn widths, exactly (half a pixel of rounding, no tolerance); merged pages found per width through data-figma-node-ids; dynamic routes at concrete URLs from generateStaticParams, the site's links or the detail frame; hover and focus; a view-transition check placed last; a Vite plugin beside each app's vite.config that strips the ids from production builds unless DEVUP_AUDIT=1; missing referencePngs and renders are listed with the export call that makes them. status then classifies every failure (runtime-error, overflow, code-drift or converter-bug for geometry - compared with the export record each export writes to .devup/exports, a converter bug written as a repro to .devup/converter-bugs and never retried - layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its Figma node, code file and fix hint, folds visualResults in, marks a finding unresolved with its evidence after maxFixAttempts (config, default 3), and returns a workQueue per URL and the finalReport. defer records a decision met after confirm as pending with its recommended option so the work never waits; assume takes it at the end, and a destructive one stays unbuilt. Done = zero fails, or listed as unresolved with evidence. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it. \ No newline at end of file +Plan which Figma screen becomes which URL before any page is written, confirm it up front, then report it URL by width (action: draft | confirm | status | assume | auditPlan | defer). draft reads the linked Section or frame (or the Bridge selection, or `screens` from an earlier devup_figma_explore) and proposes a route only from evidence: an existing page built from the frame, a nav link label bound to an existing href, a navigationTargets entry, or .devup/screen-plan.config.json (modalKeywords, routeAliases, widthGrouping). A route inferred from a layer name is routeEvidence name-match and is put to the user; with no evidence it is UNVERIFIED and asked. Only frames under the link are candidates, never the rest of the file; frames in trash, archive, draft, copy or hidden areas are excluded and listed (excludeKeywords/includeKeywords in the config). Frames sharing a name in one section are asked about per group; the same name in different sections, a screen split across sections, a frame outside any section and duplicates each get an explicit question with a "the Figma structure is wrong" option whose frames are reported, never built. It writes .devup/screen-plan.json and .md and returns `proposal` (and `proposalMarkdown`): every open decision as one table row with the recommended answer and the alternatives. Do not interview the user one decision at a time: show the table and ask ONE question with your question tool - accept it, or name the rows to change - in the language the user first wrote in (translate for display only; answers stay keyed by question id with the original labels or custom text). confirm with `acceptRecommended: true` fills every unanswered applicable question with its recommended option, so send only the changed rows in `answers`; without it, confirm refuses and names the missing ids. `questions`/`questionRounds` remain for reviewing individual rows. A confirm that puts two screens on one URL writes nothing and returns `details.conflict` (both screens and the questions placing them) and `details.retryAnswers` with a distinct suggested URL to retry with. After confirm, never ask the user: take the recommended or most conservative option and record it with assume. status compares the confirmed plan with the routes on disk and an optional devup_runtime_audit plan/report: per URL, implemented yes/no and per-width pass, fail with reason, or not-run - a width without an audit item is never pass - plus every assumption. draft also lists the stack defaults - Next App Router on vinext, a new project from dev-five-git/service-template, and the dependency update `bunx @dependency-check-updates/cli -d -u --rm` before implementing - and asks once whether a next-CLI app migrates to vinext; with several workspace apps it picks the app by meaning (the public app unless the frames clearly say admin, per screen in a mixed file) and asks only when no app looks like the public site; then the site defaults it fills in without asking (an interaction recipe per element role, restrained; one shared transition; reduced motion; view transitions always on (React's ViewTransition; skipped only when the project config opts out), verified by a runtime-audit view-transition check; metadata, lang, alt text, not-found) and asks only what needs a file or a fact: favicon and share image (from existing files or Figma logo/og nodes, never generated), site name and URL when the project does not say, analytics when the project shows it, and scroll reveal; `extraQuestions` adds the agent's own questions to the same rounds so nothing is asked after confirm; .devup/screen-plan.config.json may pre-answer them (favicon, ogImage, siteName, siteUrl, motion). status adds site completeness: favicon, metadata per URL, share image, hover/focus coverage and reduced motion as filled, asked-and-answered or missing. auditPlan writes one devup_runtime_audit plan per confirmed URL from the export artifacts: page health, overflow and clipped text at every width; the page, each section and each asset against its Figma render (devup_visual_compare calls returned by status) and the size and position of each section, card and asset at the drawn widths, exactly (half a pixel of rounding, no tolerance); merged pages found per width through data-figma-node-ids; dynamic routes at concrete URLs from generateStaticParams, the site's links or the detail frame; hover and focus; a view-transition check placed last; a Vite plugin beside each app's vite.config that strips the ids from production builds unless DEVUP_AUDIT=1; missing referencePngs and renders are listed with the export call that makes them. status then classifies every failure (runtime-error, overflow, code-drift or converter-bug for geometry - compared with the export record each export writes to .devup/exports, a converter bug written as a repro to .devup/converter-bugs and never retried - layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its Figma node, code file and fix hint, folds visualResults in, marks a finding unresolved with its evidence after maxFixAttempts (config, default 3), and returns a workQueue per URL and the finalReport. defer records a decision met after confirm as pending with its recommended option so the work never waits; assume takes it at the end, and a destructive one stays unbuilt. Done = zero fails, or listed as unresolved with evidence. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/screen_plan-ASK_INSTRUCTION-0285b268.md b/crates/devup-mcp/src/server/text/screen_plan-ASK_INSTRUCTION-0285b268.md index dcfac860..39c159dd 100644 --- a/crates/devup-mcp/src/server/text/screen_plan-ASK_INSTRUCTION-0285b268.md +++ b/crates/devup-mcp/src/server/text/screen_plan-ASK_INSTRUCTION-0285b268.md @@ -1 +1 @@ -Ask every question now, before implementing, with your question tool and never in prose. If the tool has no per-call cap (OpenCode `question`), ask the flat `questions` list in one call; each conditional question says when it applies. If it has one (Claude Code AskUserQuestion: 4), ask the questionRounds back to back in consecutive calls with no other work in between, skipping a question whose `when` was not met. Then confirm. After confirm never ask the user anything: take the recommended or most conservative option and record it with action assume. \ No newline at end of file +Ask once, before implementing anything, and do not interview the user one decision at a time. Show the `proposal` table (every open decision with the answer this plan recommends) and ask ONE question with your question tool: accept the table as it is, or name the rows to change. Ask in the language the user first wrote to you in: translate the table, the question and the option labels and descriptions for display only; the answers you send to confirm stay keyed by question id and use the original option labels (or custom text such as a route). When the user accepts, confirm with `acceptRecommended: true` and only the rows they changed in `answers`; every other question takes its recommended option. Ask a changed row's follow-up only when the user asked to review it. After confirm never ask the user anything: take the recommended or most conservative option and record it with action assume.