The Changelog Scout
The Changelog Scout: reads real git history, classifies every commit through a contract-checked capability, and drafts the release notes - features first, fixes second, docs summarized in one line.
Developer Experience
Round 3
Andrew Kane
exit 0
bundle exec ruby examples/changelog_scout.rb
a real captured run
RELEASE NOTES (last 1 commits, drafted in 210ms) ============================================================ ## Features - The live tier (real LLM runs, recorded once) + artifact pipeline
source
# frozen_string_literal: true # The Changelog Scout: reads real git history, classifies every commit # through a contract-checked capability, and drafts the release notes - # features first, fixes second, docs summarized in one line. # # bundle exec ruby examples/changelog_scout.rb [commit_count] # # Runs offline against the current repo. Swap the classifier lambda for # an LLM client when you want prose instead of parsing - the contract # stays identical. require class="s">"bundler/setup" require class="s">"agentic" ROOT = File.expand_path(class="s">"..", __dir__) count = (ARGV.first || 40).to_i # --- the classifier: one commit in, one classified entry out --------------- spec = Agentic:class="y">:CapabilitySpecification.new( name: class="s">"classify_commit", description: class="s">"Classify one commit subject for release notes", version: class="s">"1.0.0", inputs: {subject: {type: class="s">"string", required: true}}, outputs: { kind: {type: class="s">"string", required: true}, note: {type: class="s">"string", required: true}, breaking: {type: class="s">"boolean", required: true} } ) Agentic.register_capability(spec, Agentic:class="y">:CapabilityProvider.new( capability: spec, implementation: ->(inputs) { subject = inputs[class="y">:subject] kind = subject[/\A(feat|fix|docs|refactor|test|chore)/, 1] || class="s">"other" note = subject.sub(/\A\w+(\([^)]*\))?!?:\s*/, class="s">"").sub(/\A(.)/) { $1.upcase } {kind: kind, note: note, breaking: subject.include?(class="s">"!:")} } )) scribe = Agentic:class="y">:Agent.build { |a| a.name = class="s">"Scribe" } scribe.add_capability(class="s">"classify_commit") # --- the plan: classify commits in parallel, then one writer fans in -------- subjects = `git -C #{ROOT} log -#{count} --pretty=format:%s` .force_encoding(Encoding:class="y">:UTF_8).lines.map(&class="y">:strip) orchestrator = Agentic:class="y">:PlanOrchestrator.new(concurrency_limit: 8) classifications = subjects.map.with_index do |subject, i| task = Agentic:class="y">:Task.new( description: class="s">"commit #{i + 1}", agent_spec: {class="s">"name" => class="s">"Scribe", class="s">"instructions" => class="s">"classify"}, payload: subject ) orchestrator.add_task(task, agent: ->(t) { scribe.execute_capability(class="s">"classify_commit", {subject: t.payload}) }) task end notes = Agentic:class="y">:Task.new( description: class="s">"release notes", agent_spec: {class="s">"name" => class="s">"Editor", class="s">"instructions" => class="s">"draft the notes"} ) orchestrator.add_task(notes, classifications, agent: ->(t) { entries = classifications.map { |c| t.output_of(c) } grouped = entries.group_by { |e| e[class="y">:kind] } sections = [] sections << class="s">"## Breaking\n" + entries.select { |e| e[class="y">:breaking] }.map { |e| class="s">"- #{e[class="y">:note]}" }.join(class="s">"\n") if entries.any? { |e| e[class="y">:breaking] } {class="s">"feat" => class="s">"## Features", class="s">"fix" => class="s">"## Fixes", class="s">"refactor" => class="s">"## Internals"}.each do |kind, heading| items = grouped[kind] or next sections << class="s">"#{heading}\n#{items.map { |e| "- #{e[class="y">:note]}class="s">" }.join("\nclass="s">")}" end quiet = grouped.slice(class="s">"docs", class="s">"test", class="s">"chore", class="s">"other").values.flatten.size sections << class="s">"_...plus #{quiet} documentation, test, and housekeeping commits._" if quiet.positive? sections.join(class="s">"\n\n") }) result = orchestrator.execute_plan puts class="s">"RELEASE NOTES (last #{subjects.size} commits, drafted in #{(result.execution_time * 1000).round}ms)" puts class="s">"=" * 60 puts result.results[notes.id].output