decision-system S8b: move os-me callers to Decision::Model and delete the port #1266

Closed
opened 2026-10-23 18:03:54 +00:00 by jared · 2 comments
Owner

Problem

After S8, os-me loads the gem decision-model (module Decision::Model) and has the gem adapter OsMe::HeadlessClaude. But the callers still use the in-plugin port OsMe::DecisionModel: ReplyShape, AnswerSource, WrongCost::Assessor, RowLabels, and MomentExtractor. Each caller puts its criteria into one prompt text and asks one question per call. The port cannot send typed questions, it does not apply the criteria rules, and it gives no probabilities or confidence. Two live criteria texts break the gem CriteriaRules, which QuestionSet applies by default: ReplyShape accept and modify contain "not", and the AnswerSource taste text names the option rule. This ticket is the second of two S8 tickets.

Approach

Each caller takes a Decision::Model::Client and asks gem Question objects. Each caller reads the answer from the Reply by key.

Caller ask calls State Questions (in this order) Result
ReplyShape#classify(pair) 1 { 'memo' => pair.ai_text, 'reply' => pair.reply_text } Question.choice(:shape, criteria: CRITERIA, instructions: INSTRUCTION) reply.fetch(:shape).choice (a String)
AnswerSource#classify(row) 1 row.text Question.choice(:source, criteria: CRITERIA, instructions: 'Which source can answer this row?') Result.new(source: reply.fetch(:source).choice) (a Symbol, from the Symbol criteria keys)
WrongCost::Assessor#assess(row) 1 row.text Question.noul(key, instructions: INSTRUCTIONS[key]) for hard_to_undo, compounds, stays_hidden WrongCost.new(**keys.to_h { [it, reply.fetch(it).choice] })

ReplyShape::INSTRUCTION, ReplyShape::CRITERIA, AnswerSource::CRITERIA, and WrongCost::Assessor::INSTRUCTIONS keep their text unchanged. Ruling (2026-10-23): os-me keeps its measured criteria texts verbatim and builds its Claude-backed Decision::Model::Client with rules: Decision::Model::CriteriaRules::Off, one setting for all os-me questions, in the one place os-me builds its Client. The gem rule targets the small local model's literal reading of negated criteria (gem register D-6); the Sonnet labeler passed the S2 gate with these texts (83.3% on the frozen 50). Put this reason in the commit message and the History row, not in a code comment.

RowLabels.build and MomentExtractor.build make Decision::Model::Client.new(OsMe::HeadlessClaude.new(runner: HeadlessRunner.new), rules: Decision::Model::CriteriaRules::Off). MomentExtractor.build passes model: LABELER_MODEL. Thus os-me-route makes two claude calls, not four. DecisionMoment::LABELER does not change.

Delete plugins/os-me/lib/os_me/decision_model.rb and the directory plugins/os-me/lib/os_me/decision_model/ (Question, Answer, Client, CriteriaRules, HeadlessClaude, UnreadableAnswer). Tests that remain use Decision::Model::Adapters::Fixed, or a small recorder that wraps it, in place of the old fake adapters.

Required work:

  1. Ask the :shape choice question through the gem in ReplyShape — check: present Decision::Model::Question.choice(:shape in plugins/os-me/lib/os_me/reply_shape.rb
  2. Ask the :source choice question through the gem in AnswerSource — check: present Decision::Model::Question.choice(:source in plugins/os-me/lib/os_me/answer_source.rb
  3. Ask the three noul questions in one call in WrongCost::Assessor — check: present Decision::Model::Question.noul in plugins/os-me/lib/os_me/wrong_cost/assessor.rb
  4. Build the gem client with OsMe::HeadlessClaude in RowLabels and MomentExtractor — check: present Decision::Model::Client.new in plugins/os-me/lib/os_me/*.rb
  5. Delete the port namespace and update the tests that used it — check: present Decision::Model::Adapters::Fixed in plugins/os-me/test/*.rb

Acceptance criteria

  1. Given Decision::Model::Adapters::Fixed.new({ shape: { choice: 'modify', probabilities: { 'accept' => 0.1, 'modify' => 0.8, 'override' => 0.05, 'none' => 0.05 } } }), when ReplyShape.new(client: Decision::Model::Client.new(adapter)).classify(pair) runs, then it returns "modify".
  2. Given an adapter that records its call(state:, questions:) arguments and returns the Fixed reply of criterion 1, and a pair with ai_text 'MEMO-TEXT' and reply_text 'REPLY-TEXT', when classify runs, then the adapter gets one call with state { 'memo' => 'MEMO-TEXT', 'reply' => 'REPLY-TEXT' } and one question with key :shape, type :choice, criteria.keys ['accept', 'modify', 'override', 'none'], criteria equal to ReplyShape::CRITERIA, and instructions equal to ReplyShape::INSTRUCTION.
  3. Given Decision::Model::Adapters::Fixed.new({ source: { choice: 'facts', probabilities: { 'rule' => 0.1, 'precedent' => 0.1, 'facts' => 0.6, 'experiment' => 0.1, 'taste' => 0.1 } } }), when AnswerSource.new(Decision::Model::Client.new(adapter)).classify(OsMe::Row.new(text: 'row')) runs, then the result has source :facts, max_rung 2, and novel? false. With the recording adapter and the row text Move Gate::StepOutcome to OsSdlc::StepOutcome., classify makes the adapter get state equal to that text and one question with key :source, type :choice, criteria.keys [:rule, :precedent, :facts, :experiment, :taste], and instructions Which source can answer this row?.
  4. Given Decision::Model::Adapters::Fixed.new({ hard_to_undo: { noul: 0.9 }, compounds: { noul: 0.2 }, stays_hidden: { noul: 0.5 } }), when WrongCost::Assessor#assess(OsMe::Row.new(text: 'row')) runs, then it returns WrongCost.new(hard_to_undo: true, compounds: false, stays_hidden: true).
  5. Given the recording adapter, when WrongCost::Assessor#assess runs, then the adapter gets one call with state 'row' and three questions with keys [:hard_to_undo, :compounds, :stays_hidden], each type :noul and instructions WrongCost::Assessor::INSTRUCTIONS[key].
  6. Given the client that RowLabels.build and MomentExtractor.build make, when its rules are read, then they are Decision::Model::CriteriaRules::Off.
  7. Given Decision::Model::Client.new(adapter, rules: Decision::Model::CriteriaRules::Off) with the recording adapter and Adapters::Fixed reply of criterion 1, when ReplyShape classifies a pair, then the question set builds without Decision::Model::QuestionError, and the criteria sent equal ReplyShape::CRITERIA unchanged, including the accept and modify texts that contain "not".
  8. Given a row file with the text of criterion 4 and a fake claude that replies first { answers: { source: { choice: 'facts', probabilities: { rule: 0.1, precedent: 0.1, facts: 0.6, experiment: 0.1, taste: 0.1 } } } }, then { answers: { hard_to_undo: { noul: 0.1 }, compounds: { noul: 0.9 }, stays_hidden: { noul: 0.1 } } }, when os-me-route ROW_FILE runs, then stdout is {"source":"facts","max_rung":2,"novel":false,"wrong_cost":{"hard_to_undo":false,"compounds":true,"stays_hidden":false},"level":"medium","required_band":70} plus a newline, the exit status is 0, each of the two prompts includes the row text, and no third claude call occurs.
  9. Given two train sessions and a fake claude whose second reply has the text **none** and no structured output, when MomentExtractor.build(...).run runs, then it raises Decision::Model::ResponseError, only the first session is marked read, and the first claude call has --model sonnet.
  10. Given the change, when grep -rn 'DecisionModel' plugins/os-me/lib plugins/os-me/test runs, then it prints nothing, and plugins/os-me/lib/os_me/decision_model.rb and plugins/os-me/lib/os_me/decision_model/ do not exist.

Owner sketch

class OsMe::ReplyShape
  def initialize(client:); end
  def classify(pair) = @client.ask(state(pair), [question]).fetch(:shape).choice
end

class OsMe::AnswerSource
  def initialize(client); end
  def classify(row) = Result.new(source: @client.ask(row.text, [question]).fetch(:source).choice)
end

class OsMe::WrongCost::Assessor
  def initialize(client); end
  def assess(row); end # one ask, three noul questions
end

Retired tests

plugins/os-me/test/decision_model_test.rb
plugins/os-me/test/structured_answer_test.rb

Non-goals

  • Changes to OsMe::HeadlessClaude (S8) or to the gem.
  • Use of confidence, probabilities, Reply#usage, or Reply#latency in os-me records. The callers read choice only.
  • A change to DecisionMoment::LABELER.
  • The adapter comparison on the frozen 50-point sample and the S8 gate. A later ticket does it.

Blocked by: #1250 (gem dependency and OsMe::HeadlessClaude).

Origin

  • Trigger: Rewritten 2026-10-23 with the gem's real contract (email-triage session 0daf3e86) and exact criteria.
  • Improvised this session: none
  • Chain: off-design S3 build ← acceptance criteria too loose ← first ticket draft. Stop at DESIGN (docs/decision-system/build-plan.md S8; gem docs/design/core.md).
  • Root candidate: none found; searched decision-model gem, ReplyShape, AnswerSource, WrongCost
  • Where: docs/decision-system/build-plan.md S8; plugins/os-me/lib/os_me/reply_shape.rb; plugins/os-me/lib/os_me/answer_source.rb; plugins/os-me/lib/os_me/wrong_cost/assessor.rb; plugins/os-me/lib/os_me/row_labels.rb; plugins/os-me/lib/os_me/moment_extractor.rb; plugins/os-me/lib/os_me/decision_model/
  • Session: 4f3f95ea-3c6f-4d23-a0ea-e349d6ab02a5
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/4f3f95ea-3c6f-4d23-a0ea-e349d6ab02a5.jsonl

VERDICT: CREATE (user-approved; backlog search only)

## Problem After S8, os-me loads the gem `decision-model` (module `Decision::Model`) and has the gem adapter `OsMe::HeadlessClaude`. But the callers still use the in-plugin port `OsMe::DecisionModel`: `ReplyShape`, `AnswerSource`, `WrongCost::Assessor`, `RowLabels`, and `MomentExtractor`. Each caller puts its criteria into one prompt text and asks one question per call. The port cannot send typed questions, it does not apply the criteria rules, and it gives no probabilities or confidence. Two live criteria texts break the gem `CriteriaRules`, which `QuestionSet` applies by default: `ReplyShape` `accept` and `modify` contain "not", and the `AnswerSource` `taste` text names the option `rule`. This ticket is the second of two S8 tickets. ## Approach Each caller takes a `Decision::Model::Client` and asks gem `Question` objects. Each caller reads the answer from the `Reply` by key. | Caller | `ask` calls | State | Questions (in this order) | Result | |---|---|---|---|---| | `ReplyShape#classify(pair)` | 1 | `{ 'memo' => pair.ai_text, 'reply' => pair.reply_text }` | `Question.choice(:shape, criteria: CRITERIA, instructions: INSTRUCTION)` | `reply.fetch(:shape).choice` (a String) | | `AnswerSource#classify(row)` | 1 | `row.text` | `Question.choice(:source, criteria: CRITERIA, instructions: 'Which source can answer this row?')` | `Result.new(source: reply.fetch(:source).choice)` (a Symbol, from the Symbol criteria keys) | | `WrongCost::Assessor#assess(row)` | 1 | `row.text` | `Question.noul(key, instructions: INSTRUCTIONS[key])` for `hard_to_undo`, `compounds`, `stays_hidden` | `WrongCost.new(**keys.to_h { [it, reply.fetch(it).choice] })` | `ReplyShape::INSTRUCTION`, `ReplyShape::CRITERIA`, `AnswerSource::CRITERIA`, and `WrongCost::Assessor::INSTRUCTIONS` keep their text unchanged. Ruling (2026-10-23): os-me keeps its measured criteria texts verbatim and builds its Claude-backed `Decision::Model::Client` with `rules: Decision::Model::CriteriaRules::Off`, one setting for all os-me questions, in the one place os-me builds its Client. The gem rule targets the small local model's literal reading of negated criteria (gem register D-6); the Sonnet labeler passed the S2 gate with these texts (83.3% on the frozen 50). Put this reason in the commit message and the History row, not in a code comment. `RowLabels.build` and `MomentExtractor.build` make `Decision::Model::Client.new(OsMe::HeadlessClaude.new(runner: HeadlessRunner.new), rules: Decision::Model::CriteriaRules::Off)`. `MomentExtractor.build` passes `model: LABELER_MODEL`. Thus `os-me-route` makes two `claude` calls, not four. `DecisionMoment::LABELER` does not change. Delete `plugins/os-me/lib/os_me/decision_model.rb` and the directory `plugins/os-me/lib/os_me/decision_model/` (`Question`, `Answer`, `Client`, `CriteriaRules`, `HeadlessClaude`, `UnreadableAnswer`). Tests that remain use `Decision::Model::Adapters::Fixed`, or a small recorder that wraps it, in place of the old fake adapters. Required work: 1. Ask the `:shape` choice question through the gem in `ReplyShape` — check: present `Decision::Model::Question.choice(:shape` in `plugins/os-me/lib/os_me/reply_shape.rb` 2. Ask the `:source` choice question through the gem in `AnswerSource` — check: present `Decision::Model::Question.choice(:source` in `plugins/os-me/lib/os_me/answer_source.rb` 3. Ask the three noul questions in one call in `WrongCost::Assessor` — check: present `Decision::Model::Question.noul` in `plugins/os-me/lib/os_me/wrong_cost/assessor.rb` 4. Build the gem client with `OsMe::HeadlessClaude` in `RowLabels` and `MomentExtractor` — check: present `Decision::Model::Client.new` in `plugins/os-me/lib/os_me/*.rb` 5. Delete the port namespace and update the tests that used it — check: present `Decision::Model::Adapters::Fixed` in `plugins/os-me/test/*.rb` ## Acceptance criteria 1. Given `Decision::Model::Adapters::Fixed.new({ shape: { choice: 'modify', probabilities: { 'accept' => 0.1, 'modify' => 0.8, 'override' => 0.05, 'none' => 0.05 } } })`, when `ReplyShape.new(client: Decision::Model::Client.new(adapter)).classify(pair)` runs, then it returns `"modify"`. 2. Given an adapter that records its `call(state:, questions:)` arguments and returns the `Fixed` reply of criterion 1, and a pair with `ai_text` `'MEMO-TEXT'` and `reply_text` `'REPLY-TEXT'`, when `classify` runs, then the adapter gets one call with `state` `{ 'memo' => 'MEMO-TEXT', 'reply' => 'REPLY-TEXT' }` and one question with `key` `:shape`, `type` `:choice`, `criteria.keys` `['accept', 'modify', 'override', 'none']`, `criteria` equal to `ReplyShape::CRITERIA`, and `instructions` equal to `ReplyShape::INSTRUCTION`. 3. Given `Decision::Model::Adapters::Fixed.new({ source: { choice: 'facts', probabilities: { 'rule' => 0.1, 'precedent' => 0.1, 'facts' => 0.6, 'experiment' => 0.1, 'taste' => 0.1 } } })`, when `AnswerSource.new(Decision::Model::Client.new(adapter)).classify(OsMe::Row.new(text: 'row'))` runs, then the result has `source` `:facts`, `max_rung` `2`, and `novel?` `false`. With the recording adapter and the row text `Move Gate::StepOutcome to OsSdlc::StepOutcome.`, `classify` makes the adapter get `state` equal to that text and one question with `key` `:source`, `type` `:choice`, `criteria.keys` `[:rule, :precedent, :facts, :experiment, :taste]`, and `instructions` `Which source can answer this row?`. 4. Given `Decision::Model::Adapters::Fixed.new({ hard_to_undo: { noul: 0.9 }, compounds: { noul: 0.2 }, stays_hidden: { noul: 0.5 } })`, when `WrongCost::Assessor#assess(OsMe::Row.new(text: 'row'))` runs, then it returns `WrongCost.new(hard_to_undo: true, compounds: false, stays_hidden: true)`. 5. Given the recording adapter, when `WrongCost::Assessor#assess` runs, then the adapter gets one call with `state` `'row'` and three questions with keys `[:hard_to_undo, :compounds, :stays_hidden]`, each `type` `:noul` and `instructions` `WrongCost::Assessor::INSTRUCTIONS[key]`. 6. Given the client that `RowLabels.build` and `MomentExtractor.build` make, when its rules are read, then they are `Decision::Model::CriteriaRules::Off`. 7. Given `Decision::Model::Client.new(adapter, rules: Decision::Model::CriteriaRules::Off)` with the recording adapter and `Adapters::Fixed` reply of criterion 1, when `ReplyShape` classifies a pair, then the question set builds without `Decision::Model::QuestionError`, and the criteria sent equal `ReplyShape::CRITERIA` unchanged, including the `accept` and `modify` texts that contain "not". 8. Given a row file with the text of criterion 4 and a fake `claude` that replies first `{ answers: { source: { choice: 'facts', probabilities: { rule: 0.1, precedent: 0.1, facts: 0.6, experiment: 0.1, taste: 0.1 } } } }`, then `{ answers: { hard_to_undo: { noul: 0.1 }, compounds: { noul: 0.9 }, stays_hidden: { noul: 0.1 } } }`, when `os-me-route ROW_FILE` runs, then stdout is `{"source":"facts","max_rung":2,"novel":false,"wrong_cost":{"hard_to_undo":false,"compounds":true,"stays_hidden":false},"level":"medium","required_band":70}` plus a newline, the exit status is 0, each of the two prompts includes the row text, and no third `claude` call occurs. 9. Given two train sessions and a fake `claude` whose second reply has the text `**none**` and no structured output, when `MomentExtractor.build(...).run` runs, then it raises `Decision::Model::ResponseError`, only the first session is marked read, and the first `claude` call has `--model sonnet`. 10. Given the change, when `grep -rn 'DecisionModel' plugins/os-me/lib plugins/os-me/test` runs, then it prints nothing, and `plugins/os-me/lib/os_me/decision_model.rb` and `plugins/os-me/lib/os_me/decision_model/` do not exist. ## Owner sketch ```ruby class OsMe::ReplyShape def initialize(client:); end def classify(pair) = @client.ask(state(pair), [question]).fetch(:shape).choice end class OsMe::AnswerSource def initialize(client); end def classify(row) = Result.new(source: @client.ask(row.text, [question]).fetch(:source).choice) end class OsMe::WrongCost::Assessor def initialize(client); end def assess(row); end # one ask, three noul questions end ``` ## Retired tests plugins/os-me/test/decision_model_test.rb plugins/os-me/test/structured_answer_test.rb ## Non-goals - Changes to `OsMe::HeadlessClaude` (S8) or to the gem. - Use of `confidence`, `probabilities`, `Reply#usage`, or `Reply#latency` in os-me records. The callers read `choice` only. - A change to `DecisionMoment::LABELER`. - The adapter comparison on the frozen 50-point sample and the S8 gate. A later ticket does it. Blocked by: #1250 (gem dependency and `OsMe::HeadlessClaude`). ## Origin - Trigger: Rewritten 2026-10-23 with the gem's real contract (email-triage session 0daf3e86) and exact criteria. - Improvised this session: none - Chain: off-design S3 build ← acceptance criteria too loose ← first ticket draft. Stop at `DESIGN` (`docs/decision-system/build-plan.md` S8; gem `docs/design/core.md`). - Root candidate: none found; searched decision-model gem, ReplyShape, AnswerSource, WrongCost - Where: docs/decision-system/build-plan.md S8; plugins/os-me/lib/os_me/reply_shape.rb; plugins/os-me/lib/os_me/answer_source.rb; plugins/os-me/lib/os_me/wrong_cost/assessor.rb; plugins/os-me/lib/os_me/row_labels.rb; plugins/os-me/lib/os_me/moment_extractor.rb; plugins/os-me/lib/os_me/decision_model/ - Session: 4f3f95ea-3c6f-4d23-a0ea-e349d6ab02a5 - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/4f3f95ea-3c6f-4d23-a0ea-e349d6ab02a5.jsonl VERDICT: CREATE (user-approved; backlog search only)
Author
Owner

Work started on branch ticket-1266 (os-sdlc implement).

Work started on branch ticket-1266 (os-sdlc implement).
Author
Owner

Resolution

Done: os-me callers ask typed Decision::Model questions; port OsMe::DecisionModel deleted; OsMe::LabelerClient.for builds every Client with CriteriaRules::Off; ReplyShape eval moved to the gem

Evidence: Hand-finished after escalation 67 (implementation 134, test fixture defect): main b33ade05, e4464c7e, 69ebc806; os-me suite 250 runs 0 failures on main; rubocop clean; opus design audit pass, F1-F3 fixed

Follow-ups: #1274 S8 gate adapter comparison; #1275 verifier brief omits staged changes; guard refusing subagent-stop recorded as occurrence on #1096

## Resolution **Done:** os-me callers ask typed Decision::Model questions; port OsMe::DecisionModel deleted; OsMe::LabelerClient.for builds every Client with CriteriaRules::Off; ReplyShape eval moved to the gem **Evidence:** Hand-finished after escalation 67 (implementation 134, test fixture defect): main b33ade05, e4464c7e, 69ebc806; os-me suite 250 runs 0 failures on main; rubocop clean; opus design audit pass, F1-F3 fixed **Follow-ups:** #1274 S8 gate adapter comparison; #1275 verifier brief omits staged changes; guard refusing subagent-stop recorded as occurrence on #1096
jared closed this issue 2026-10-23 18:40:43 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jared/cc-os#1266
No description provided.