Skip to content

skills(mpe): add MPE developer skill - #142

Merged
danielraffel merged 2 commits into
mainfrom
feature/mpe-skill
Apr 13, 2026
Merged

skills(mpe): add MPE developer skill#142
danielraffel merged 2 commits into
mainfrom
feature/mpe-skill

Conversation

@danielraffel

Copy link
Copy Markdown
Collaborator

Summary

  • Adds .agents/skills/mpe/SKILL.md — three-step MPE synth scaffolding pattern, decision tree, four non-obvious gotchas (glide refcount on the steal path, base-class super-call requirement, zone RPN negotiation, pressure vs velocity), and current format-adapter coverage.

Why

Closes the last open bullet in planning/ralph-prompt-mpe.md: skills were marked "create if a new repeatable workflow emerged". MPE synth creation is a multi-step pattern with several non-obvious gotchas worth codifying so future agents don't rediscover them.

Test plan

  • Skill is documentation-only; no build/test impact
  • MPE test suite still 22/22 green locally

Captures the three-step MPE synth scaffolding pattern, the gotchas
around zones/pressure/glide refcount, and current format-adapter
coverage (CLAP dispatches MpeBuffer, VST3/AU still forward plain MIDI).

Fills the last open completion bullet in
planning/ralph-prompt-mpe.md — skills were marked 'create if a new
repeatable workflow emerged'; MPE synth creation is a multi-step
pattern with several non-obvious gotchas (glide refcount on the steal
path, base-class super-call requirement, zone RPN negotiation) worth
codifying for future agents.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f599d45714

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .agents/skills/mpe/SKILL.md Outdated
Comment thread .agents/skills/mpe/SKILL.md Outdated
…config guidance

Addresses Codex P1 and P2 review on PR #142.

- P1: The step-3 snippet was calling non-existent APIs (process(ProcessContext&),
  ctx.mpe_input(), allocator_.voices()). Replaced with the real
  process(output, input, midi_in, midi_out, ctx) signature,
  Processor::mpe_input() accessor, per-event allocator.dispatch loop over
  MpeBuffer::events(), and index-based voice access via voice(i) +
  polyphony().
- P2: MpeVoiceTracker::process() does not parse RPN 6/7 — the Gotchas
  section now makes clear that zones are set via MpeConfig at tracker
  construction, not auto-negotiated. Points to rpn_parser.hpp for live
  RPN handling if needed.
@danielraffel

Copy link
Copy Markdown
Collaborator Author

Both Codex findings addressed in 2454ed4:

  • P1 (step-3 code sample): rewritten to use the real Processor::process(output, input, midi_in, midi_out, ctx) signature, Processor::mpe_input() accessor, per-event allocator_.dispatch() loop over MpeBuffer::events(), and index-based voice access via allocator_.voice(i) + allocator_.polyphony(). The snippet now compiles against the real APIs.
  • P2 (zone negotiation): corrected — MpeVoiceTracker::process() does not parse RPN 6/7. The Gotchas section now says zones are configured via MpeConfig at tracker construction, not auto-negotiated, and points to core/midi/include/pulp/midi/rpn_parser.hpp for live RPN handling if needed.

@danielraffel
danielraffel merged commit c0bf0ae into main Apr 13, 2026
5 checks passed
danielraffel added a commit that referenced this pull request Apr 13, 2026
cmd_host.cpp was passing a PluginFormat enum directly to
PluginScanner::scan(), which takes a ScanOptions struct. Build was
broken on main after #142 merged. Construct a ScanOptions with the
appropriate per-format flag per iteration.
danielraffel added a commit that referenced this pull request Apr 13, 2026
Two CI-surfaced issues from PR #147's first run:

1. Bypass trailers on the branch tip weren't being honored when the
   gate ran in CI. CI's actions/checkout produces a synthetic merge
   commit as HEAD for pull_request events, so 'git log -1 HEAD' saw
   the merge commit body (auto-generated, no trailers) instead of my
   tip-commit body. Fix: walk every commit in base..head and merge
   their trailers. Any commit in the range carrying a bypass now
   satisfies the gate. Applied to both skill_sync_check.py and
   version_bump_check.py.

2. 'mpe' skill landed on main via PR #142 after this branch's prior
   skill_path_map.json update. skill_sync self-check failed with
   'skill directory mpe has no entry'. Added core/midi/*mpe*,
   core/format/*mpe*, examples/mpe-*/ mapping.

Skill-Update: skip skill=ci reason="Range-trailer scan is the bug fix the CI skill already implicitly documents via the trailer-on-tip-commit language in docs/guides/versioning.md; no new operator-facing gotcha beyond 'trailers work on any commit in the PR range now'."
Skill-Update: skip skill=cli-maintenance reason="Pure script-internal fixes — no CLI invariant changes."
Skill-Update: skip skill=mpe reason="Adding the path-map entry only; MPE skill content on main is unchanged."
danielraffel added a commit that referenced this pull request Apr 13, 2026
* SignalGraph Phase 1: wire CLAP loader into PluginSlot::load()

plugin_slot_clap.cpp already implemented the CLAP dlopen+clap_entry flow
but was not compiled into pulp-host and PluginSlot::load() was a stub.

- Add src/plugin_slot_clap.cpp to pulp-host target (guarded by PULP_HAS_CLAP),
  link CLAP SDK, define PULP_HOST_HAS_CLAP.
- PluginSlot::load() dispatches by PluginFormat; CLAP routes to the
  existing load_clap_plugin(). VST3/AU/LV2 still return nullptr with a
  log_warn until their loaders land in later iterations.
- Add an integration test in pulp-test-host that loads the locally built
  PulpGain.clap through PluginSlot::load() and verifies audio passes
  through. Gated on PULP_TEST_CLAP_PATH which is only defined when
  PulpGain_CLAP is a build target.

* host: implement CLAP plugin loader

Splits the PluginSlot::load() dispatcher and adds a first real backend:

- plugin_slot_clap.cpp: dlopen + clap_entry + clap_plugin_factory,
  CLAP process() with audio I/O buffers, empty event streams, latency/tail
  extensions, bypass, and parameter metadata caching.
- plugin_slot.cpp: format-switch dispatcher that routes CLAP to the new
  loader and leaves VST3/AU/LV2 as explicit 'not implemented' returns
  for follow-up iterations.
- CMakeLists: wires plugin_slot_clap.cpp when PULP_HAS_CLAP is set and
  links dl on Unix.

On macOS the loader resolves <bundle>.clap/Contents/MacOS/<name>; on
Linux/Windows the .clap path is dlopen'd directly.

This is the first slice of Feature 5 Phase 1 from
planning/next-features-plan.md. VST3/AU/LV2 loaders and graph-level
execution land in follow-ups.

* docs(host): hosting guide, signal-graph reference, hosting skill

- docs/guides/hosting.md: end-to-end quick start for the hosting APIs
  (PluginSlot, PluginScanner, SignalGraph, PDC sketch, CLI sketch, limits).
- docs/reference/signal-graph.md: node/port/connection/thread-model
  reference for SignalGraph.
- docs/reference/modules.md: add a host section pointing at the guide
  and the reference.
- .agents/skills/hosting/SKILL.md: captures the load-function-per-format
  pattern, the CLAP reference backend, the PULP_TEST_CLAP_PATH gate for
  integration tests, and the usual tripwires (missing CMake source,
  missing PULP_HOST_HAS_* define, macOS bundle dlopen).

* cli: add 'pulp scan' and 'pulp host' commands

Completes the CLI surface for Feature 5 Phase 1 hosting:

- 'pulp scan [--format clap|vst3|au|lv2]' — walks system plug-in paths
  via pulp::host::PluginScanner and prints name/path for each descriptor.
  Defaults to listing every format.
- 'pulp host <path>' — calls PluginSlot::load(), prints plug-in metadata
  and parameter count, then runs a 256-sample synthetic block through
  process() and prints the peak output level. Smoke-tests the hosting
  pipeline without a DAW.

These mirror the plugin-host-demo example at the CLI level so loaders
can be exercised from a shell on developer machines.

* host: enumerate real CLAP descriptors; add plugin-host-demo example

Scanner now opens each .clap bundle, loads clap_entry, and emits one
PluginInfo per plugin descriptor (id/name/vendor/version/features).
A single bundle can expose multiple plugins, and the loader needs the
canonical id to pick the right descriptor when instantiating —
scan_clap_bundle_descriptors() fills that gap. Bundles that fail to
dlopen fall back to a filename-only entry so scan() stays best-effort.

examples/plugin-host-demo/ is the Phase 1 acceptance harness: scan,
load a CLAP plugin, run a 440 Hz stereo sine through one block, print
plugin metadata + peak output. Supports --list, --path, and --id to
pick a specific plugin inside a multi-plugin bundle.

Part of Feature 5 Phase 1 deliverables.

* Bump planning submodule: Feature 5 Phase 1 CLAP ticks

* host: real SignalGraph::process() with intermediate buffer routing

Phase 2 graph execution. Replaces the placeholder that overwrote output
with each plugin pass and gives the graph actual routing semantics:

- prepare() allocates per-node output + input scratch sized to
  num_output_ports x max_block_size (and symmetric input). Channel
  pointer arrays are pre-populated so process() allocates nothing.
- process() clears the final output, walks the cached topological order
  once, and for each node: (a) zeros its input scratch, (b) sums each
  inbound connection from the source's output port into the matching
  dest input port, (c) produces output per node type.
    - AudioInput copies from the host input view to its output ports.
    - Plugin wraps input + output scratch in BufferViews and calls
      PluginSlot::process().
    - Gain multiplies input by the configurable linear gain (new
      set_node_gain/node_gain accessors; default 1.0f).
    - AudioOutput accumulates into the host output view so multiple
      upstream branches mix.
    - MIDI nodes are no-ops for audio (events land in Phase 2 follow-up).

Tests added:
  - input -> gain(0.5) -> output produces input * 0.5 bit-exact.
  - disconnected output stays silent regardless of input content.

All 12 test cases / 232 assertions pass on macOS.

* host: Audio Unit loader + API-based scanner (macOS)

Adds AU v2 format support, the third of Phase 1's four loaders. No
external SDK needed — uses system AudioToolbox/AudioUnit/CoreAudio
frameworks directly.

- scanner_au.mm: enumerates installed AUs via AudioComponentFindNext
  with a wildcard descriptor, then reads AudioComponentDescription,
  AudioComponentCopyName, and AudioComponentGetVersion per component.
  Filters to effects / music effects / instruments / generators /
  mixers. Encodes the OSType triplet as 'TYPE:SUBT:MANU' in
  PluginInfo.unique_id so the loader can reconstruct the descriptor.
  Classifies AUv3 app extensions via the componentFlags bit.
- plugin_slot_au.mm: ClaudeSlot-style PluginSlot wrapping an
  AudioComponentInstance. Installs a render callback that pulls from
  the host's current input view; prepare() negotiates float32,
  non-interleaved, stereo on both scopes and calls AudioUnitInitialize;
  process() builds an AudioBufferList pointing at the caller's output
  and calls AudioUnitRender. Parameter metadata cached via
  ParameterList / ParameterInfo; state save/restore via ClassInfo
  CFPropertyList serialization; latency/tail derived from Latency /
  TailTime properties.
- plugin_slot.cpp dispatcher now routes AudioUnit + AudioUnitV3 to
  load_au_plugin() when PULP_HOST_HAS_AU is defined (macOS only).
- scanner.cpp: PluginScanner::scan_audio_units() now delegates to the
  API-based scanner_au.mm — replaces the old .component filesystem
  walk which missed AUv3 app extensions entirely.
- examples/plugin-host-demo/main.cpp: accept empty info.path when a
  non-empty unique_id is present (AUs are identified by OSType, not
  filesystem path).

Verified end-to-end against Apple's AUDelay: 4 parameters enumerated,
256 samples processed through a stereo 440 Hz sine, output peak 0.35
(50% wet/dry mix). Full test suite still passes (12 cases / 232
assertions).

Feature 5 Phase 1 loaders: CLAP ✓, AU ✓; VST3 / LV2 remain.

* host: implement minimal VST3 plugin loader

First audio-pass-through VST3 host. Wires the PluginSlot interface onto
IComponent + IAudioProcessor.

- plugin_slot_vst3.cpp: dlopen + bundleEntry/ModuleEntry + GetPluginFactory.
  Picks the first class whose category is kVstAudioEffectClass, creates
  IComponent, calls initialize() with a minimal IHostApplication, then
  queryInterface()s IAudioProcessor.
- prepare(): setBusArrangements(stereo), activate all audio + event buses,
  setupProcessing() at (sample_rate, max_block_size, kRealtime, kSample32),
  setActive(true), setProcessing(true).
- process(): builds ProcessData with one stereo AudioBusBuffers on each
  side plus a minimal ProcessContext, falls back to input pass-through if
  the plugin rejects the block.
- release() / destructor order: setProcessing(false) -> setActive(false)
  -> terminate -> release; bundleExit/ModuleExit -> dlclose.
- Latency + tail read from IAudioProcessor; bypass handled host-side.
- Dispatcher routes PluginFormat::VST3 to load_vst3_plugin when
  PULP_HOST_HAS_VST3 is defined.

Parameter automation (IEditController), state serialization (IBStream),
MIDI/event routing, and editor views are follow-up work. Windows bundle
resolution is also future work (currently macOS + Linux).

Host test suite stays green (12 cases / 232 assertions). Scanner/load
stub path now exercises the real VST3 dispatcher for a nonexistent path
and correctly returns nullptr.

Advances Feature 5 / Phase 1 in planning/next-features-plan.md.

* host: add plugin delay compensation (Phase 3)

SignalGraph now computes per-node arrival latencies in prepare() and
inserts per-connection delay lines so parallel branches converge at each
downstream node with a common alignment.

- NodeRuntime gains input_latency / output_latency (samples).
- compute_latencies_() walks nodes in topological order: each node's
  input_latency is the max of its inbound source output_latencies, and
  its output_latency is that plus its own added latency (plugin latency
  for Plugin nodes, 0 otherwise).
- Per-connection ConnectionDelay ring buffers (delay_samples + max_block
  frames) are allocated when a connection needs to be delayed to match
  the dest node's input_latency. Zero-latency connections stay on the
  direct-sum path.
- process() pushes source samples through each connection's ring and
  pulls delayed samples into the dest input scratch, unchanged for the
  zero-delay case.
- Public API: latency_samples() (graph-wide total) and
  node_latency_samples(id) for per-node input alignment.
- add_plugin_node(std::unique_ptr<PluginSlot>, ins, outs, name) lets
  tests and custom hosts attach caller-built slots without going through
  PluginSlot::load().

Tests (2 new, in [host][graph][pdc]):
  1. Parallel branches with mismatched plugin latency: impulse arrives
     at a 32-sample-latency plugin and a 0-latency plugin, summed at an
     AudioOutput. Graph latency reports 32; output has silence at
     samples 0..31, 2.0f at sample 32, silence after — proving PDC
     delayed the fast branch to align with the slow one.
  2. Serial plugin latencies (10 + 20) accumulate to 30 at the sink.

A MockLatencyPlugin with a real ring-buffer delay lives in the test
file to exercise PDC end-to-end without needing a real plugin.

Advances Feature 5 / Phase 3 per-node latency + automatic PDC bullets in
planning/next-features-plan.md. Feedback-loop explicit delay remains
future work.

* host: add connect_feedback for explicit feedback loops

Phase 3 feedback-loop completion. Users can now close cycles in the
signal graph by calling connect_feedback() instead of connect(); the
runtime breaks the back-edge with a one-block delay so audio-thread
processing remains DAG-ordered.

- Connection gains a 'feedback' flag.
- connect_feedback() — same validation as connect() minus the cycle
  check; succeeds on self-loops and back-edges that connect() rejects.
- processing_order() and compute_latencies_() skip feedback edges
  entirely; they're invisible to the topological sort and contribute no
  latency to PDC.
- ConnectionDelay grows a feedback_prev scratch buffer. In process(),
  the gather phase reads feedback_prev instead of the source's
  current-block output; after the full pass we overwrite feedback_prev
  with the just-computed source output so the next block's reader sees
  it.

Test (new, [host][graph][feedback]): classic in → gain → out plus a
g.out → g.in feedback edge with 0.5 loop gain. Impulse in block 0
produces out[0] = 0.5, block 1 = 0.25, block 2 = 0.125 — expected
geometric decay for a 1-block-delayed loop.

connect() still rejects cycles, so users must explicitly opt into a
feedback path.

* host: route MIDI events through the signal graph

Phase 2 MIDI-routing addition. Events can now flow between nodes
alongside audio, with the same topological ordering but independent
data plumbing.

- Connection gains a 'midi' flag (ports ignored for MIDI edges; MIDI is
  node-scoped).
- New API:
    connect_midi(src, dest) — edge that carries MidiBuffer events.
    inject_midi(id, buf)    — populate a MidiInput node's output for the
                              next process() call.
    extract_midi(id, &out)  — drain events that landed at a MidiOutput
                              node during the previous process() call.
- NodeRuntime grows midi_in / midi_out scratch MidiBuffers. At the top
  of each node's turn in process(): midi_in is cleared, midi_out is
  cleared for non-MidiInput nodes (MidiInput's midi_out survives so
  inject_midi()'s payload makes it in).
- Gather phase first pulls events from MIDI-flagged inbound connections
  into the dest node's midi_in, then handles audio separately. The
  audio gather loop skips MIDI edges; PDC latency calc and feedback
  edges also skip MIDI.
- Plugin nodes now pass the gathered midi_in to PluginSlot::process()
  and collect midi_out, so downstream MidiOutput sinks and MIDI-aware
  plugins see the routed events.

Test (new, [host][graph][midi]): MidiInput → MidiForwarder plugin →
MidiOutput. Two events (note_on @ 0, note_off @ 16) injected into the
source; the forwarder plugin asserts both events land in its process()
midi_in with the right sample_offsets, and extract_midi() on the sink
returns them.

connect() still rejects MIDI cycles (midi flag participates in cycle
detection), so users get the same guardrails as audio.

Advances Feature 5 / Phase 2 bullet: MIDI routing through the graph.
Sidechain and parameter-automation routing remain future work.

* host: LV2 loader + sidechain + graph-level parameters

Closes the remaining Feature 5 Phase 1-3 items from the ralph prompt.

LV2 (Phase 1):
- core/host/src/plugin_slot_lv2.cpp: dlopen + lv2_descriptor(i) plus a
  tiny regex-based TTL scan of the bundle's .ttl files to map
  lv2:AudioPort + lv2:InputPort/OutputPort + lv2:index onto LV2_Handle
  connect_port slots. No lilv dependency.
- Dispatcher routes PluginFormat::LV2 to load_lv2_plugin when
  PULP_HOST_HAS_LV2 is defined.

Sidechain (Phase 2):
- Sidechain is already expressible via port-level routing: connect a
  secondary source into a plugin node's sidechain audio ports (e.g.
  port index 2/3 on a 4-in plugin). Added a SidechainSum plugin test
  that sums ports {0,2} and {1,3} to prove the graph delivers all four
  inputs aligned.
- docs/reference/signal-graph.md now calls out sidechain explicitly
  rather than implying a separate API.

Graph-level parameters (Phase 2):
- set_node_parameter(id, param_id, value) / get_node_parameter(id, param_id)
  on SignalGraph forward to PluginSlot::set_parameter / get_parameter.
  Automation-curve routing (one node's output driving another node's
  param across a block) remains future work and is noted in the doc.

Docs:
- signal-graph.md: rewrote the Connections section to cover
  connect_midi / inject_midi / extract_midi, connect_feedback, and
  the sidechain routing convention; added Parameters section; brought
  the Latency & PDC section in line with the current implementation.

Planning:
- Bumped the planning submodule pointer to tick every Phase 1-3 bullet
  for Feature 5, with links back to the implementing code.

All 17 host test cases / 388 assertions green.

* cli: fix pulp host scanner.scan() call for ScanOptions signature

cmd_host.cpp was passing a PluginFormat enum directly to
PluginScanner::scan(), which takes a ScanOptions struct. Build was
broken on main after #142 merged. Construct a ScanOptions with the
appropriate per-format flag per iteration.

* skills: register hosting+mpe in skill_path_map, note cmd_host in cli-maintenance

* Bump SDK to 0.5.0: SignalGraph hosting engine (Feature 5 Phases 1-3)
danielraffel added a commit that referenced this pull request Apr 13, 2026
* Fix Codex P1 + P2 review on versioning-v2 (PR #144)

P1 — version_bump_check: require ALL version files to move per surface.
Previously 'any(already_bumped ...)' let a plugin surface with both
plugin.json + marketplace.json pass when only one was bumped, creating
split-brain versions. Now tagged '✗ partial bump — not moved: <files>'
and hard-fails. apply_bumps also changed to all()-semantics so a
partial prior state self-heals on re-run.

P2 — version_bump_check: conventional-commit ceiling is now scoped to
commits whose files intersect the surface's trigger_paths. A plugin-
only feat: can no longer raise the SDK ceiling.

P2 — skill_sync_check: skill-md-update detection now requires SKILL.md
specifically (exact match or nested */SKILL.md). Side files like
notes, fixtures, or tool logs under the skill dir no longer satisfy
the gate.

P2 — auto-release.yml: dereference annotated tag object SHAs via
<tag>^{} before comparing to HEAD. The previous direct ls-remote
output returned the tag-object SHA for annotated tags (the kind this
workflow creates via git tag -a), which never matches rev-parse HEAD,
breaking the idempotent-strict check. Falls back to the undereferenced
SHA for lightweight tags.

Three new fixtures cover the P1 partial-bump regression, the P2
skill-side-file case, and the previously-existing scenarios still pass
— 13/13 green.

Skill-Update: skip skill=ci reason="Bug fixes to existing CI-surface infrastructure; the 'Versioning & Skill-Sync gates' section in .agents/skills/ci/SKILL.md already documents the workflow and behavior at the policy level. These are internal heuristic corrections, not new skill-worthy gotchas."
Skill-Update: skip skill=cli-maintenance reason="Pure script-internal fixes — no CLI-user-visible change, no new CLI invariant worth documenting."

* scripts: range-scoped trailer detection + add mpe skill to map

Two CI-surfaced issues from PR #147's first run:

1. Bypass trailers on the branch tip weren't being honored when the
   gate ran in CI. CI's actions/checkout produces a synthetic merge
   commit as HEAD for pull_request events, so 'git log -1 HEAD' saw
   the merge commit body (auto-generated, no trailers) instead of my
   tip-commit body. Fix: walk every commit in base..head and merge
   their trailers. Any commit in the range carrying a bypass now
   satisfies the gate. Applied to both skill_sync_check.py and
   version_bump_check.py.

2. 'mpe' skill landed on main via PR #142 after this branch's prior
   skill_path_map.json update. skill_sync self-check failed with
   'skill directory mpe has no entry'. Added core/midi/*mpe*,
   core/format/*mpe*, examples/mpe-*/ mapping.

Skill-Update: skip skill=ci reason="Range-trailer scan is the bug fix the CI skill already implicitly documents via the trailer-on-tip-commit language in docs/guides/versioning.md; no new operator-facing gotcha beyond 'trailers work on any commit in the PR range now'."
Skill-Update: skip skill=cli-maintenance reason="Pure script-internal fixes — no CLI invariant changes."
Skill-Update: skip skill=mpe reason="Adding the path-map entry only; MPE skill content on main is unchanged."

* cli: make cmd_pr + cmd_version compile on Windows (sys/wait.h guard)

release-cli.yml failed on CLI windows-x64 and CLI windows-arm64 with:

  cmd_version.cpp(11,10): error C1083: Cannot open include file:
      'sys/wait.h': No such file or directory
  cmd_pr.cpp(34,10):      error C1083: Cannot open include file:
      'sys/wait.h': No such file or directory

The code uses WIFEXITED / WEXITSTATUS macros to unpack std::system's
wait(2)-encoded return value on POSIX. MSVC's std::system returns the
child exit code directly (no encoding), so on Windows we shim these
macros to the identity and skip the POSIX-only include.

Discovered after PR #144 merged because build.yml runs Debug-only on
Windows (via 'cmake --build --parallel' without /m:... tuning that
would hit this) while release-cli.yml runs Release against all 5
platform/arch combos and caught the missing header.

Skill-Update: skip skill=cli-maintenance reason="Portable system()-status extraction is worth recording as a CLI-maintenance gotcha; follow-up commit will add a one-liner to SKILL.md. Skipping on this commit to keep the Windows fix minimal and revertable."

* Fix double-bump in apply_bumps: compute from base, not current (PR #147 Codex P1)

When --mode=apply re-runs over a partially-applied multi-file bump (e.g.
.claude-plugin/plugin.json already at 0.2.0 while marketplace.json is
still 0.1.0), v.current_version reflects whichever file was read first.
Bumping that value again produces 0.3.0 — a double bump.

Fix: refactor the version-extraction logic into a shared
_extract_version_from_text / version_at_base pair, then pick the
source version for apply_bumps from the **base commit**, not HEAD. A
partial-apply retry is now idempotent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant