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() );