decision-system S8: decision-model gem adapters #1250
Labels
No labels
P0
P1
P2
P3
bug
category/code-change
category/cross-repo
category/eval-run
category/investigation
category/no-test-target
category/owner-ruling
category/prose-artifact
category/recurring-chore
category/structure
category/umbrella
crc-refactor
create
delete
enhancement
filed-by/agent
filed-by/user
frozen
lint-rule
needs-info
needs-triage
next
plugin/cc-architect
plugin/os
plugin/os-adr
plugin/os-aidd-lint
plugin/os-backlog
plugin/os-context
plugin/os-doc-hygiene
plugin/os-sdlc
plugin/os-vault
project/cc-os
ready-for-agent
ready-for-human
recurring
review
rule-proposal
update
waiting
wayfinder:grilling
wayfinder:map
wayfinder:map
wayfinder:research
wayfinder:task
wayfinder:task
wontfix
worklist/deviations
worklist/lint-rule
worklist/new-implement-build
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
jared/cc-os#1250
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
os-me asks its model questions through the in-plugin port
OsMe::DecisionModel(S2a). The port has one adapter,HeadlessClaude, and aQuestionthat holds onlytextandoptions. The shared gemdecision-model(moduleDecision::Model, email-triage #9) is now available. It givesClient#ask(state, questions)->Reply, typedQuestionobjects, one confidence formula for all adapters, and theCloudflareJevandSystemOne(Ollama) adapters. The gem has no Claude adapter. Thus os-me must keep aHeadlessClaudeadapter that obeys the gem adapter contract:nameandcall(state:, questions:)->Decision::Model::RawReply. This ticket is the first of two S8 tickets. It adds the gem dependency and the new adapter. S8b moves the callers to the gem and deletes the port.Approach
O-7 is settled: the ADR challenge on 2026-10-23 rejected an ADR. One commit can reverse the dependency, and ADR-0166 and ADR-0257 already decide a private Forgejo gem over SSH. Name both ADRs in the commit message.
Add to
plugins/os-me/Gemfile, aftergem 'zeitwerk':Run
bundle lockinplugins/os-meand commitGemfile.lock. Addrequire 'decision/model'toplugins/os-me/lib/os_me.rbafterrequire 'bundler/setup'.Add
OsMe::HeadlessClaudeinplugins/os-me/lib/os_me/headless_claude.rb. It is a gem adapter. It does not replaceOsMe::DecisionModel::HeadlessClaudein this ticket; S8b deletes the old class.name@runner.structured(prompt:, cwd: Dir.tmpdir, model: @model, schema:)promptINSTRUCTION, a blank line, thenJSON.generate(state: state, questions: Decision::Model::Wire::QuestionEncoder.call(questions))INSTRUCTIONAnswer each question about the state. For a choice question, pick one option and give a probability from 0 to 1 for each option. For a noul question, give the probability from 0 to 1 that the answer is yes.answersHash instructured_outputDecision::Model::ResponseErrorwith the messageheadless claude gave no structured answers: #{result.text}The schema (keys as strings after
JSON.generate):Required work:
decision-modelgit gem to the os-me Gemfile — check: presentgem 'decision-model', git: 'ssh://git@forgejo.swansoncloud.com:2222/jared/decision-model.git'inplugins/os-me/Gemfiledecision-modelinplugins/os-me/Gemfile.lockrequire 'decision/model'inplugins/os-me/lib/os_me.rbOsMe::HeadlessClaude— check: presentdef call(state:, questions:)inplugins/os-me/lib/os_me/headless_claude.rbRawReplyfrom the adapter — check: presentDecision::Model::RawReply.newinplugins/os-me/lib/os_me/headless_claude.rbAcceptance criteria
In criteria 3 to 9,
runneris a fake withstructured(**kwargs)that recordskwargsand returnsOsMe::HeadlessResult.new(text: 'TEXT', input_tokens: 120, output_tokens: 30, structured_output: OUT).shapeisDecision::Model::Question.choice(:shape, criteria: { 'accept' => 'The reply approves it.', 'modify' => 'The reply changes it.' }, instructions: 'Classify the reply.').setisDecision::Model::QuestionSet.new([shape]).plugins/os-me/lib/os_me.rbis required, whenDecision::Model::ClientandDecision::Model::Adapters::Fixedare referenced, then both are defined.OsMe::HeadlessClaude.new(runner:, model: 'sonnet'), whennameruns, then it returns"headless:sonnet"; with nomodel:, it returns"headless:default".setand state{ 'memo' => 'M', 'reply' => 'R' }, whencall(state:, questions: set)runs, then the runner gets one call withcwd: Dir.tmpdirandmodel: 'sonnet', andkwargs[:prompt]equals"#{OsMe::HeadlessClaude::INSTRUCTION}\n\n#{JSON.generate(state: { 'memo' => 'M', 'reply' => 'R' }, questions: { 'shape' => { 'type' => 'choice', 'instructions' => 'Classify the reply.', 'criteria' => { 'accept' => 'The reply approves it.', 'modify' => 'The reply changes it.' } } })}".JSON.parse(JSON.generate(kwargs[:schema]))equals{"type"=>"object","required"=>["answers"],"properties"=>{"answers"=>{"type"=>"object","required"=>["shape"],"properties"=>{"shape"=>{"type"=>"object","required"=>["choice","probabilities"],"properties"=>{"choice"=>{"type"=>"string","enum"=>["accept","modify"]},"probabilities"=>{"type"=>"object","required"=>["accept","modify"],"properties"=>{"accept"=>{"type"=>"number","minimum"=>0,"maximum"=>1},"modify"=>{"type"=>"number","minimum"=>0,"maximum"=>1}}}}}}}}}.OUT = { 'answers' => { 'shape' => { 'choice' => 'modify', 'probabilities' => { 'accept' => 0.2, 'modify' => 0.8 } } } }, whencall(state: 'S', questions: set)runs, then it returnsDecision::Model::RawReply.new(answers: OUT['answers'], usage: Decision::Model::Usage.new(input_tokens: 120, output_tokens: 30), model: 'claude-sonnet').OUTof criterion 5, whenDecision::Model::Client.new(OsMe::HeadlessClaude.new(runner:, model: 'sonnet')).ask('S', [shape])runs, then the reply hasadapter"headless:sonnet", andreply.fetch(:shape)haschoice"modify",probabilities{ 'accept' => 0.2, 'modify' => 0.8 },confidencewithin 1e-6 of0.6, andmodel"claude-sonnet".Decision::Model::Question.noul(:hard_to_undo, instructions: 'A'),(:compounds, instructions: 'B'),(:stays_hidden, instructions: 'C')andOUT = { 'answers' => { 'hard_to_undo' => { 'noul' => 0.9 }, 'compounds' => { 'noul' => 0.2 }, 'stays_hidden' => { 'noul' => 0.5 } } }, when the client of criterion 6 asks them with state'row', then the schema property of each key is{"type"=>"object","required"=>["noul"],"properties"=>{"noul"=>{"type"=>"number","minimum"=>0,"maximum"=>1}}}, and the answer choices in key order are[true, false, true], andreply.fetch(:hard_to_undo).valueis0.9.OUT = niland result text'**none**', whencall(state: 'S', questions: set)runs, then it raisesDecision::Model::ResponseErrorwhose message isheadless claude gave no structured answers: **none**.OUT = { 'answers' => { 'other' => { 'noul' => 0.5 } } }, when the client of criterion 6 asks[shape], then it raisesDecision::Model::ResponseErrorwhose message starts withheadless:sonnet: no answer for question :shape.claudeexecutable onPATH(theFakeClaudeFixtureinplugins/os-me/test/fake_claude_fixture.rb) that replies withstructured_output{ answers: { shape: { choice: 'accept', probabilities: { accept: 0.9, modify: 0.1 } } } }, whenOsMe::HeadlessClaude.new(runner: OsMe::HeadlessRunner.new)is called withset, then the argv holds--json-schemawith the schema of criterion 4,--setting-sourceswith'', and no--modelflag.Owner sketch
Non-goals
ReplyShape,AnswerSource,WrongCost::Assessor,RowLabels, andMomentExtractorto the gem, and deletingOsMe::DecisionModel(S8b).scorequestions throughOsMe::HeadlessClaude. No caller asks one.--json-schemaenum limits the choice; the gem passes an unknown choice through to the caller.Adapters::CloudflareJevandAdapters::SystemOne. They are in the gem.HeadlessClaude, lower cost per run). A later ticket does it after S8b.Origin
DESIGN(docs/decision-system/build-plan.mdS8; gemdocs/design/adapters.md).VERDICT: CREATE (user-approved; backlog search only)
Unblocked 2026-10-23 (message from email-triage session 0daf3e86). Gem
decision-model(moduleDecision::Model), repo jared/decision-model, local /home/jared/dev/ruby-gems/decision-model; Gemfile:gem "decision-model", git: "ssh://git@forgejo.swansoncloud.com:2222/jared/decision-model.git". Readdocs/design/core.mdandadapters.mdin the gem first. Contract changes against docs/decision-system/decision-model.md: (1) an adapter'scall(state:, questions:)returns aRawReply;Client#ask(state, questions)returns aReply(read-only hash by key, plus usage, latency, model, adapter); HeadlessClaude must return a RawReply. (2)Question.new(key:, type:, criteria:, instructions:), nooptions:; options come from criteria keys; types choice, noul, score. (3) Answer fields: key, type, choice, probabilities, confidence (margin, D-11), entropy_confidence (D-13), vendor_confidence, value, model;answer.probability(option = true). (4) CriteriaRules runs by default in QuestionSet. (5)Adapters::Fixedfor caller tests;Price+Usage#+give cost per run. O-8 done: Ollama 0.40.9 at http://localhost:11434, models tev1:4b (gem default), d3:4b, d3-4b-16k, nimble-v3 (CC BY-NC, personal only). Compareconfidence/entropy_confidence, notvendor_confidence. Gem S4/S5 (building blocks, comparison) still in progress.Contract changes from the shared gem (email-triage #9), for this ticket. Gem:
decision-model, moduleDecision::Model, https://forgejo.swansoncloud.com/jared/decision-model (gem "decision-model", git: "ssh://git@forgejo.swansoncloud.com:2222/jared/decision-model.git"). Design:docs/design/core.md,adapters.mdin the gem repo.docs/decision-system/decision-model.md){key => Answer}RawReply(wire answers, usage, model)Client#ask(state, questions){key => Answer}Reply: read-only hash by key plususage,latency,model,adapterQuestion(key, type, options, criteria, instructions)Question.new(key:, type:, criteria:, instructions:); options come fromcriteriakeys; Ollama requiresinstructions("" accepted)Answer(key, choice, probabilities, confidence, model)type,entropy_confidence,vendor_confidence,value(noul p, score);confidence= jev margin formula on every adapterCloudflareJevCloudflareJev.build(account: CloudflareAccount.new(...)); caller passes credentialsAdapters::Fixedfor caller tests;PriceandUsage#+for cost per runHeadlessClaudestays in cc-os and must return aRawReply. Ollama 0.40.9 withtev1:4b,d3:4b,d3-4b-16kis installed (O-8 done);nimble-v3is pulling.Work started on branch ticket-1250 (/os-sdlc:implement, S8).
Resolution
Done: OsMe::HeadlessClaude adapter added; decision-model gem in os-me Gemfile
Evidence: implementation 133 merge-ready; suite and lint gates pass; commit on ticket-1250 touches headless_claude.rb, headless_claude_test.rb, Gemfile, Gemfile.lock, os_me.rb
Follow-ups: #1266 (S8b) moves the callers to the gem with CriteriaRules::Off; adapter comparison and the S8 gate get a ticket after S8b