From 87e06260392af366c062d72fa42a02ee62b6ebb6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 15 Sep 2026 16:30:52 +0900 Subject: [PATCH] feat(skills): carry the changepacks skill and report what a .changepacks directory obliges Agents using devup-mcp open pull requests without a changepack log, and the cause turns out to be documentation rather than the agent. The invocation changepacks documents is the bare command, which opens an interactive selection UI. In an agent shell, a CI job, or any non-TTY context that hangs or is cancelled, so the step is skipped, the version never moves, and the change reaches the base branch unreleased. The non-interactive form already existed and was documented nowhere a reader would find it while writing a pull request: bunx @changepacks/cli --yes --update-type patch --message "..." changepacks/changepacks#131 adds a consumer-facing SKILL.md that leads with it. This vendors that document as an embedded skill, so `devup_skills install` delivers it with no network. Apache-2.0 makes the redistribution fine; this is the first carried skill from outside dev-five-git. Detection is the other half, and the more important one. A skill only helps a caller who installed and loaded it, while the pull request that lacks a changepack comes from the caller who never thought to ask. `devup_skills` now reports a `repoObligations.changepacks` block whenever the workspace has a `.changepacks` directory: - the non-interactive command, filled in - why the bare one hangs, so the reader does not try it - the tracked-path patterns read out of config.json rather than assumed, because which paths need a changepack differs per repository - the base branch and any changepack logs already pending on this branch `instructions` already tells every session to call `devup_skills` before writing devup-ui code, so this arrives without anyone asking for it. Two tests had `dev-five-git` spelled into the provenance assertion and failed on the first skill vendored from another organisation. They derive the repository from the record now. Verified by using it: this commit's own changepack was created with the exact command the skill teaches, non-interactively, exit 0. --- .../changepack_log_changepacks_skill.json | 7 + crates/devup-mcp/src/server/skills.rs | 127 +++++++++++++++- .../src/server/skills/changepacks/SKILL.md | 136 ++++++++++++++++++ .../devup-mcp/src/server/skills/manifest.json | 20 +++ crates/devup-mcp/tests/skills_install.rs | 8 +- 5 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 .changepacks/changepack_log_changepacks_skill.json create mode 100644 crates/devup-mcp/src/server/skills/changepacks/SKILL.md diff --git a/.changepacks/changepack_log_changepacks_skill.json b/.changepacks/changepack_log_changepacks_skill.json new file mode 100644 index 0000000..9f4d13e --- /dev/null +++ b/.changepacks/changepack_log_changepacks_skill.json @@ -0,0 +1,7 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor" + }, + "note": "devup-mcp now carries the changepacks skill and reports the obligation a .changepacks directory creates. Agents routinely opened pull requests without a changepack log, and the cause was documentation rather than the agent: the invocation the project documents is the bare interactive command, which hangs or is cancelled in a non-TTY shell, so the step was skipped and the change reached the base branch with no version bump. The non-interactive form already existed and was undocumented, so changepacks/changepacks#131 added a consumer-facing SKILL.md leading with it, and this vendors that document as an embedded skill installable with no network. Detection is the other half: devup_skills now reports a repoObligations.changepacks block whenever the workspace has a .changepacks directory, carrying the exact non-interactive command, why the bare one hangs, the tracked-path patterns read out of config.json rather than assumed, the base branch and any pending logs. That reaches a caller who never asked about changepacks, which is precisely the caller who produces the pull request that lacks one. Two tests had hardcoded dev-five-git into the provenance assertion and failed on the first skill vendored from another organisation; they now derive the repository from the record.", + "date": "2026-09-15T07:29:38.828557500Z" +} diff --git a/crates/devup-mcp/src/server/skills.rs b/crates/devup-mcp/src/server/skills.rs index 42c3a9f..cc33e1c 100644 --- a/crates/devup-mcp/src/server/skills.rs +++ b/crates/devup-mcp/src/server/skills.rs @@ -136,6 +136,10 @@ const EMBEDDED: &[(&str, Documents)] = &[ ), ], ), + ( + "changepacks", + &[("SKILL.md", include_str!("skills/changepacks/SKILL.md"))], + ), ( "vespera", &[("SKILL.md", include_str!("skills/vespera/SKILL.md"))], @@ -689,7 +693,7 @@ pub fn report(project: &Path) -> serde_json::Value { .iter() .filter(|entry| entry["installed"] == false) .count(); - serde_json::json!({ + let mut report = serde_json::json!({ "workspace": project.display().to_string(), "skillRoots": { "known": SKILL_ROOTS, @@ -707,7 +711,71 @@ pub fn report(project: &Path) -> serde_json::Value { keeps applying for every later session, which is the thing reading a document \ once does not do.", "boundary": "devup-mcp installs only carried skills: embedded skills prefer current upstream documents with offline fallback, and own skills use the binary. External skills are never fetched or written; their install command is yours to run.", - }) + }); + if let Some(obligation) = changepacks_obligation(project) { + report["repoObligations"] = serde_json::json!({ "changepacks": obligation }); + } + report +} + +/// What a `.changepacks/` directory obliges a pull request in this workspace to +/// carry, or `None` when the repository does not use changepacks. +/// +/// Reported here rather than left to the skill because the skill only helps a +/// caller who installed and loaded it, while this is in the response of a tool +/// `instructions` already tells every session to call. The failure being +/// addressed is a pull request that silently ships without a version bump, and +/// an agent that never asked about changepacks is exactly the one that produces +/// it. +/// +/// Reads the config rather than assuming a rule: which paths are tracked is +/// decided by `ignore` with `!` negations, and it differs per repository. +fn changepacks_obligation(project: &Path) -> Option { + let directory = project.join(".changepacks"); + let config_path = directory.join("config.json"); + if !config_path.is_file() { + return None; + } + let config: serde_json::Value = std::fs::read_to_string(&config_path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or(serde_json::Value::Null); + + // A pending log is one already written for an unreleased change. Its + // presence answers "has someone on this branch done this already", which is + // the question an agent about to add a second one needs answered. + let pending = std::fs::read_dir(&directory) + .map(|entries| { + entries + .flatten() + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| name.starts_with("changepack_log_") && name.ends_with(".json")) + .collect::>() + }) + .unwrap_or_default(); + + Some(serde_json::json!({ + "detected": display_path(&directory), + "obligation": "A pull request that changes a tracked path must add a changepack log. \ + Without one the version never moves, so the change reaches the base branch \ + and is never released.", + "createWith": "bunx @changepacks/cli --yes --update-type --message \"\"", + "whyNotBare": "Running the tool with no flags opens an interactive selection UI, which \ + hangs or is cancelled in a non-TTY shell. That is the usual reason a pull \ + request arrives without the changepack it needed.", + "tracks": config.get("ignore").cloned().unwrap_or(serde_json::Value::Null), + "tracksNote": "Patterns from .changepacks/config.json. A leading `!` marks a tracked path; \ + everything else is ignored. Whether your change needs a changepack is decided \ + by this list, not by a general rule.", + "baseBranch": config.get("baseBranch").cloned().unwrap_or(serde_json::Value::Null), + "pendingLogs": pending, + "skill": "changepacks", + })) +} + +/// A path as a reader would type it, with the separators their editor shows. +fn display_path(path: &Path) -> String { + path.display().to_string() } /// Writes the documents of the carried skills that are missing. @@ -1151,11 +1219,15 @@ mod tests { let head = &document[..end]; assert!(head.contains("name:"), "{}: {head}", skill.record.name); // Whichever note this origin gets, it belongs after the block. + // The repository comes from the record rather than a literal org: + // a carried skill does not have to be one of ours, and hardcoding + // `dev-five-git/` failed the first skill vendored from elsewhere. let opener = if skill.record.origin == Origin::Own { - "Authored in dev-five-git/" + format!("Authored in {}", skill.record.repo) } else { - "Vendored from dev-five-git/" + format!("Vendored from {}", skill.record.repo) }; + let opener = opener.as_str(); assert!( !head.contains(opener), "{}: the note must sit outside the frontmatter block", @@ -1186,4 +1258,51 @@ mod tests { // opened. assert_eq!(frontmatter_end("intro\n\n---\n\nmore\n"), None); } + + /// The obligation has to reach a caller who never asked about changepacks, + /// because that caller is the one who opens the pull request without one. + /// It also has to stay absent everywhere else: a repository that does not + /// use changepacks must not be told to run it. + #[test] + fn a_changepacks_directory_is_reported_as_an_obligation() { + let project = scratch("changepacks"); + + assert!( + report(&project)["repoObligations"].is_null(), + "a workspace with no .changepacks must carry no obligation" + ); + + let directory = project.join(".changepacks"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join("config.json"), + r#"{"ignore":["**","!/crates/*/Cargo.toml"],"baseBranch":"main"}"#, + ) + .unwrap(); + std::fs::write(directory.join("changepack_log_existing.json"), "{}").unwrap(); + // Not a changepack log; it must not be counted as one. + std::fs::write(directory.join("publish.tgz"), "").unwrap(); + + let found = report(&project)["repoObligations"]["changepacks"].clone(); + assert!(!found.is_null(), "the directory was not detected"); + + // The command has to be the non-interactive one. Bare `changepacks` + // opens a selection UI that hangs in the shells this runs in, which is + // the whole reason the step gets skipped. + let command = found["createWith"].as_str().unwrap(); + assert!(command.contains("--yes"), "{command}"); + assert!(command.contains("--update-type"), "{command}"); + assert!(command.contains("--message"), "{command}"); + + // Read from the config, never assumed: which paths are tracked differs + // per repository and deciding it here would be a guess. + assert_eq!(found["tracks"][1], "!/crates/*/Cargo.toml"); + assert_eq!(found["baseBranch"], "main"); + + let pending = found["pendingLogs"].as_array().unwrap(); + assert_eq!(pending.len(), 1, "{pending:?}"); + assert_eq!(pending[0], "changepack_log_existing.json"); + + let _ = std::fs::remove_dir_all(&project); + } } diff --git a/crates/devup-mcp/src/server/skills/changepacks/SKILL.md b/crates/devup-mcp/src/server/skills/changepacks/SKILL.md new file mode 100644 index 0000000..4daf375 --- /dev/null +++ b/crates/devup-mcp/src/server/skills/changepacks/SKILL.md @@ -0,0 +1,136 @@ +--- +name: changepacks +description: | + Version and changelog management for multi-language monorepos. Read this before opening a pull request in any repository that has a `.changepacks/` directory. + + TRIGGER WHEN: + - The repository has a `.changepacks/` directory + - Opening a pull request, or preparing a commit that will become one + - A CI check named "changepack required" (or similar) failed + - Asked to release, bump a version, or write a changelog entry + - Running `changepacks`, `npx @changepacks/cli`, or `bunx @changepacks/cli` + + Trigger keywords: changepack, changepacks, changelog, version bump, release, + "changepack required", monorepo versioning, npx @changepacks/cli, bunx @changepacks/cli +--- + +# changepacks + +A repository with a `.changepacks/` directory manages its versions with +[changepacks](https://github.com/changepacks/changepacks). In such a repository +**a pull request that changes a tracked package must add a changepack log**, or +the version never moves and the change ships to the default branch and is never +released. + +## Use the non-interactive form + +This is the single most important thing on this page. Running the tool with no +arguments opens an interactive selection UI: + +```bash +changepacks # interactive - prompts for projects, bump level and notes +``` + +In an agent shell, a CI job, or any non-TTY context that **hangs or is +cancelled**, which is the usual reason a pull request arrives without the +changepack it needed. Pass the three answers as flags instead: + +```bash +bunx @changepacks/cli --yes --update-type patch --message "what changed and why" +``` + +| Flag | Short | Meaning | +|------|-------|---------| +| `--yes` | `-y` | Take every changed project; do not prompt for selection | +| `--update-type` | `-u` | `major`, `minor` or `patch` | +| `--message` | `-m` | The release note | +| `--filter` | `-f` | `workspace` or `package` | +| `--language` | `-l` | Restrict to one language; repeatable | + +Any package manager works — use the one the repository already uses: + +```bash +bunx @changepacks/cli -y -u patch -m "..." # bun +npx @changepacks/cli -y -u patch -m "..." # npm +changepacks -y -u patch -m "..." # installed binary +``` + +## Which changes need one + +`.changepacks/config.json` decides it, not a general rule. The `ignore` array is +a list of glob patterns where a leading `!` means *tracked*: + +```jsonc +// tracks only crate manifests +{ "ignore": ["**", "!/crates/*/Cargo.toml"], "baseBranch": "main" } + +// tracks every package and one binding manifest +{ "ignore": ["*", "!packages/*/*", "!bindings/*/package.json"] } +``` + +Read that file before deciding. A documentation-only change in a repository +whose config tracks `packages/*/*` needs no changepack; the same change inside +`packages/` does. + +To see what the tool itself thinks changed: + +```bash +changepacks check # list projects and their change state +changepacks check --tree # with the dependency tree +changepacks check --remote # compare against the remote base branch +``` + +## Choosing the bump + +| Level | Use when | +|-------|----------| +| `major` | A consumer must change their code to upgrade | +| `minor` | New capability, existing usage keeps working | +| `patch` | Fix, correction, or an internal change that ships | + +A corrected document or a re-vendored asset **is** a shipped change if the +package carries it. "It is only docs" is about the repository, not about what +users receive. + +## What it produces + +One file, which you commit with your change: + +``` +.changepacks/changepack_log_.json +``` + +```json +{ + "changes": { "crates/my-crate/Cargo.toml": "Patch" }, + "note": "Explains what changed and why, for someone reading the changelog later.", + "date": "2026-01-01T00:00:00+09:00" +} +``` + +The note becomes the changelog entry. Write the reason, not the diff — the +commit already has the diff. + +## The rest of the cycle + +You normally only create the log. The remaining steps are usually automated on +the default branch, and running them by hand in a pull request is wrong: + +```bash +changepacks update --dry-run # preview the version bumps a merge would apply +changepacks update # apply them (usually CI's job, not yours) +changepacks publish # release in dependency order (usually CI's job) +``` + +## If CI says a changepack is required + +That check is comparing your changed paths against `.changepacks/config.json`. +Add the log and push: + +```bash +bunx @changepacks/cli -y -u patch -m "" +git add .changepacks && git commit -m "chore: add changepack" && git push +``` + +Do not satisfy the check by reverting the tracked file. The change is wanted; +the record of it is what was missing. diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index 80dfaa6..48b0941 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -60,6 +60,26 @@ "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devfive-frontend", "latestUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devfive-frontend" }, + { + "name": "changepacks", + "origin": "embedded", + "title": "changepacks conventions", + "description": "Version and changelog management for multi-language monorepos: when a pull request needs a changepack log, how to create one without an interactive prompt, and how .changepacks/config.json decides which paths are tracked.", + "usedFor": "Every repository devup-mcp writes into manages its versions this way. A pull request that changes a tracked path without a changepack log never moves the version, so the change reaches the base branch and is never released - and the invocation the project documents is interactive, which hangs in the shells an agent runs in. That combination is why the step gets skipped.", + "repo": "changepacks/changepacks", + "path": "SKILL.md", + "commit": "ec1f4b025ed781856eb8650e880ece9935be3351", + "committedAt": "2026-09-15T07:25:28Z", + "documents": [ + { + "path": "SKILL.md", + "bytes": 4700, + "sha256": "5477d13f7ebaf79fdf701b7e8d5b3071e09300b9ec2e55ecd51ccd223981b071" + } + ], + "sourceUrl": "https://github.com/changepacks/changepacks/blob/ec1f4b025ed781856eb8650e880ece9935be3351/SKILL.md", + "latestUrl": "https://github.com/changepacks/changepacks/blob/HEAD/SKILL.md" + }, { "name": "vespera", "origin": "embedded", diff --git a/crates/devup-mcp/tests/skills_install.rs b/crates/devup-mcp/tests/skills_install.rs index 7a4ca20..5c52ec1 100644 --- a/crates/devup-mcp/tests/skills_install.rs +++ b/crates/devup-mcp/tests/skills_install.rs @@ -155,9 +155,13 @@ async fn a_bare_workspace_reports_the_gap_and_one_call_closes_it() -> anyhow::Re .find(|path| path.file_name().is_some_and(|name| name == "SKILL.md")) .unwrap_or_else(|| panic!("no SKILL.md among the written files: {entry}")); let body = std::fs::read_to_string(entry_document)?; + // Asserted on the shape of the note, not on an organisation: a carried + // skill does not have to be one of ours, and spelling `dev-five-git/` + // here failed the first skill vendored from another org. assert!( - body.contains("Vendored from dev-five-git/") - || body.contains("Authored in dev-five-git/"), + body.contains("Vendored from ") + || body.contains("Authored in ") + || body.contains("Fetched from "), "an installed skill must carry its provenance: {}", entry_document.display() );