Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_changepacks_skill.json
Original file line number Diff line number Diff line change
@@ -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"
}
127 changes: 123 additions & 4 deletions crates/devup-mcp/src/server/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))],
Expand Down Expand Up @@ -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,
Expand All @@ -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<serde_json::Value> {
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::<Vec<_>>()
})
.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 <major|minor|patch> --message \"<why this change exists>\"",
"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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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);
}
}
136 changes: 136 additions & 0 deletions crates/devup-mcp/src/server/skills/changepacks/SKILL.md
Original file line number Diff line number Diff line change
@@ -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_<name>.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 "<why this change exists>"
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.
20 changes: 20 additions & 0 deletions crates/devup-mcp/src/server/skills/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions crates/devup-mcp/tests/skills_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
Expand Down