decision-system S7: more moment kinds #1249

Closed
opened 2026-10-23 15:17:27 +00:00 by jared · 2 comments
Owner

Problem

S2 reads only memo and reply moments. Before S7b can store question-answer, correction, direction, and over-escalation moments, os-me needs two objects that do not exist:

  • A model-backed classifier that tells the kind of a non-memo exchange. Today no object can do this.
  • A filter for latency. Latency.of(pair) measures the time from the AI message to the reply, and that time includes the time when Jared is AFK. The spike measured a median of 426 s and a p90 of 1,696 s. Because of the AFK time, latency_s is not a valid ping-cost signal (docs/decision-system/learning-loops.md: "only replies within one active stretch").

Approach

OsMe::MomentKind follows the shape of OsMe::ReplyShape: one typed gem question, Decision::Model::Question.choice(:kind, criteria: CRITERIA, instructions: INSTRUCTION), asked through the client that the caller gives (OsMe::LabelerClient.for(adapter)). The state is { 'ai' => pair.ai_text, 'reply' => pair.reply_text }. classify(pair) returns the choice as a String. The options are, in this order: question_answer, correction, direction, over_escalation, none. none means "no decision moment". memo_reply is not an option, because MemoDetector finds memos by text before MomentKind runs (docs/decision-system/extraction.md, pipeline diagram).

OsMe::ActiveStretch keeps a latency only when the reply comes in the same active stretch as the AI message: seconds <= IDLE_GAP_S with IDLE_GAP_S = 900. Out of the stretch, filter returns the same Latency with seconds: nil. reply_chars stays. DecisionMoment.memo_reply applies the filter, so latency_s is null in each new moment with a gap over 900 s. The record keeps ai_at and reply_at, so a later change of the gap can compute the latency again from the record.

This ticket does not connect MomentKind to MomentExtractor. S7b does that.

Required work:

  1. Add the model-backed MomentKind with the five options and the criteria in the owner sketch — check: present class MomentKind in plugins/os-me/lib/os_me/moment_kind.rb
  2. Add the ActiveStretch latency filter with a 900 s idle gap — check: present IDLE_GAP_S = 900 in plugins/os-me/lib/os_me/active_stretch.rb
  3. Apply the filter in DecisionMoment.memo_reply — check: present ActiveStretch in plugins/os-me/lib/os_me/decision_moment.rb

Fixture pairs for the tests (AI text, then reply text):

Kind AI text Reply text
question_answer Should the hook run on resume as well as on startup? Only on startup.
correction I renamed config.rb to settings.rb and updated the callers. No, revert that rename. config.rb is the name the docs use.
direction The tests pass. Next I will read the cursor code. Before that, add a budget flag to the CLI.
over_escalation Should I name the constant IDLE_GAP_S or IDLE_GAP_SECONDS? You decide. Do not ask me about names.
none Done. All 42 tests pass. thanks

Acceptance criteria

  1. Given each of the five fixture pairs in the Approach table and a Decision::Model::Adapters::Fixed adapter (through OsMe::LabelerClient.for) that answers kind with that pair's kind, when OsMe::MomentKind.new(client:).classify(pair) runs, then it returns that kind as a String ('question_answer', 'correction', 'direction', 'over_escalation', 'none').
  2. Given the correction fixture pair and the RecordingAdapter from plugins/os-me/test/recording_adapter.rb, when classify runs, then the adapter records exactly one call with exactly one question, whose key is :kind, whose type is :choice, and whose options are ['question_answer', 'correction', 'direction', 'over_escalation', 'none'] in that order.
  3. Given the correction fixture pair and the RecordingAdapter, when classify runs, then the recorded state is { 'ai' => 'I renamed config.rb to settings.rb and updated the callers.', 'reply' => 'No, revert that rename. config.rb is the name the docs use.' }.
  4. Given OsMe::MomentKind::KINDS, when it is read, then it equals ['question_answer', 'correction', 'direction', 'over_escalation'] (the options without none).
  5. Given OsMe::Latency.new(seconds: 900, reply_chars: 5) and OsMe::Latency.new(seconds: 901, reply_chars: 5), when OsMe::ActiveStretch.new.active? runs on each, then it returns true for 900 and false for 901.
  6. Given OsMe::Latency.new(seconds: 901, reply_chars: 5), when OsMe::ActiveStretch.new.filter runs, then it returns OsMe::Latency.new(seconds: nil, reply_chars: 5); given seconds: 900, it returns the latency unchanged.
  7. Given a Pair with ai_at 2026-10-01T10:00:00Z, reply_at 2026-10-01T10:14:00Z, and reply text yes, go ahead, when OsMe::DecisionMoment.memo_reply builds the moment, then latency_s is 840 and reply_chars is 13.
  8. Given the same pair with reply_at 2026-10-01T12:00:00Z, when OsMe::DecisionMoment.memo_reply builds the moment, then latency_s is nil, reply_chars is 13, ai_at is 2026-10-01T10:00:00Z, and reply_at is 2026-10-01T12:00:00Z.
  9. Given a Store and a train session with one memo pair (the S2 fixture memo at 2026-10-01T10:00:00Z, reply yes, go ahead at 2026-10-01T12:00:00Z) and a fake claude on PATH that answers shape accept, when os-me-extract --store <store> --transcript-root <root> --budget 20 runs, then it exits 0 and moments.jsonl holds one row with kind memo_reply, latency_s null, and reply_chars 13.

Owner sketch

module OsMe
  class MomentKind
    CRITERIA = {
      'question_answer' => 'The AI asked a question or offered options without a full decision memo, and the reply answers that question.',
      'correction' => 'The AI did or decided something on its own, and the reply says it was wrong and asks to undo or change it.',
      'direction' => 'The AI did not ask for a ruling, and the reply gives a new instruction or chooses a direction for the work.',
      'over_escalation' => 'The AI asked for a ruling, and the reply hands the decision back or says the answer was obvious, for example "you decide", "obviously", or "why ask me".',
      'none' => 'The reply gives no ruling and no instruction: thanks, small talk, or a status question.'
    }.freeze
    KINDS = (CRITERIA.keys - ['none']).freeze
    INSTRUCTION = 'Classify the decision moment in this exchange between the AI message and the reply. Pick the one kind whose criterion fits best.'

    def initialize(client:)
      @client = client
    end

    def classify(pair)
      @client.ask({ 'ai' => pair.ai_text, 'reply' => pair.reply_text }, [question]).fetch(:kind).choice
    end

    private

    def question = Decision::Model::Question.choice(:kind, criteria: CRITERIA, instructions: INSTRUCTION)
  end

  class ActiveStretch
    IDLE_GAP_S = 900

    def active?(latency) = latency.seconds <= IDLE_GAP_S
    def filter(latency) = active?(latency) ? latency : latency.with(seconds: nil)
  end
end

Non-goals

  • Connecting MomentKind to MomentExtractor, the moment consumers, and budget counts for the new calls: S7b.
  • Tuning the MomentKind criteria. The S7 gate (an agent checks a sample of each kind; 80% agreement) is orchestrator work after S7b.
  • Changing rows that are already in moments.jsonl.
  • Demotion (S9).

Origin

  • Trigger: User asked for the decision-system build tickets this session; S7 is split into S7a (this ticket) and S7b because it needs more than 10 acceptance criteria
  • Improvised this session: none
  • Chain: no ticket for this stage ← the build plan names the stage and the user asked for the tickets. Stop at DESIGN (docs/decision-system/build-plan.md S7).
  • Root candidate: none found; searched MomentKind, moment kinds, ActiveStretch, latency filter, os-me
  • Where: docs/decision-system/build-plan.md S7; docs/decision-system/extraction.md; plugins/os-me/lib/os_me/
  • Session: 4f3f95ea-3c6f-4d23-a0ea-e349d6ab02a5
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os--claude-worktrees-ticket-1250/4f3f95ea-3c6f-4d23-a0ea-e349d6ab02a5.jsonl

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

## Problem S2 reads only memo and reply moments. Before S7b can store question-answer, correction, direction, and over-escalation moments, os-me needs two objects that do not exist: - A model-backed classifier that tells the kind of a non-memo exchange. Today no object can do this. - A filter for latency. `Latency.of(pair)` measures the time from the AI message to the reply, and that time includes the time when Jared is AFK. The spike measured a median of 426 s and a p90 of 1,696 s. Because of the AFK time, `latency_s` is not a valid ping-cost signal (`docs/decision-system/learning-loops.md`: "only replies within one active stretch"). ## Approach `OsMe::MomentKind` follows the shape of `OsMe::ReplyShape`: one typed gem question, `Decision::Model::Question.choice(:kind, criteria: CRITERIA, instructions: INSTRUCTION)`, asked through the client that the caller gives (`OsMe::LabelerClient.for(adapter)`). The state is `{ 'ai' => pair.ai_text, 'reply' => pair.reply_text }`. `classify(pair)` returns the choice as a String. The options are, in this order: `question_answer`, `correction`, `direction`, `over_escalation`, `none`. `none` means "no decision moment". `memo_reply` is not an option, because `MemoDetector` finds memos by text before `MomentKind` runs (`docs/decision-system/extraction.md`, pipeline diagram). `OsMe::ActiveStretch` keeps a latency only when the reply comes in the same active stretch as the AI message: `seconds <= IDLE_GAP_S` with `IDLE_GAP_S = 900`. Out of the stretch, `filter` returns the same `Latency` with `seconds: nil`. `reply_chars` stays. `DecisionMoment.memo_reply` applies the filter, so `latency_s` is null in each new moment with a gap over 900 s. The record keeps `ai_at` and `reply_at`, so a later change of the gap can compute the latency again from the record. This ticket does not connect `MomentKind` to `MomentExtractor`. S7b does that. Required work: 1. Add the model-backed `MomentKind` with the five options and the criteria in the owner sketch — check: present `class MomentKind` in `plugins/os-me/lib/os_me/moment_kind.rb` 2. Add the `ActiveStretch` latency filter with a 900 s idle gap — check: present `IDLE_GAP_S = 900` in `plugins/os-me/lib/os_me/active_stretch.rb` 3. Apply the filter in `DecisionMoment.memo_reply` — check: present `ActiveStretch` in `plugins/os-me/lib/os_me/decision_moment.rb` Fixture pairs for the tests (AI text, then reply text): | Kind | AI text | Reply text | |---|---|---| | `question_answer` | `Should the hook run on resume as well as on startup?` | `Only on startup.` | | `correction` | `I renamed config.rb to settings.rb and updated the callers.` | `No, revert that rename. config.rb is the name the docs use.` | | `direction` | `The tests pass. Next I will read the cursor code.` | `Before that, add a budget flag to the CLI.` | | `over_escalation` | `Should I name the constant IDLE_GAP_S or IDLE_GAP_SECONDS?` | `You decide. Do not ask me about names.` | | `none` | `Done. All 42 tests pass.` | `thanks` | ## Acceptance criteria 1. Given each of the five fixture pairs in the Approach table and a `Decision::Model::Adapters::Fixed` adapter (through `OsMe::LabelerClient.for`) that answers `kind` with that pair's kind, when `OsMe::MomentKind.new(client:).classify(pair)` runs, then it returns that kind as a String (`'question_answer'`, `'correction'`, `'direction'`, `'over_escalation'`, `'none'`). 2. Given the `correction` fixture pair and the `RecordingAdapter` from `plugins/os-me/test/recording_adapter.rb`, when `classify` runs, then the adapter records exactly one call with exactly one question, whose `key` is `:kind`, whose `type` is `:choice`, and whose `options` are `['question_answer', 'correction', 'direction', 'over_escalation', 'none']` in that order. 3. Given the `correction` fixture pair and the `RecordingAdapter`, when `classify` runs, then the recorded state is `{ 'ai' => 'I renamed config.rb to settings.rb and updated the callers.', 'reply' => 'No, revert that rename. config.rb is the name the docs use.' }`. 4. Given `OsMe::MomentKind::KINDS`, when it is read, then it equals `['question_answer', 'correction', 'direction', 'over_escalation']` (the options without `none`). 5. Given `OsMe::Latency.new(seconds: 900, reply_chars: 5)` and `OsMe::Latency.new(seconds: 901, reply_chars: 5)`, when `OsMe::ActiveStretch.new.active?` runs on each, then it returns `true` for 900 and `false` for 901. 6. Given `OsMe::Latency.new(seconds: 901, reply_chars: 5)`, when `OsMe::ActiveStretch.new.filter` runs, then it returns `OsMe::Latency.new(seconds: nil, reply_chars: 5)`; given `seconds: 900`, it returns the latency unchanged. 7. Given a `Pair` with `ai_at` `2026-10-01T10:00:00Z`, `reply_at` `2026-10-01T10:14:00Z`, and reply text `yes, go ahead`, when `OsMe::DecisionMoment.memo_reply` builds the moment, then `latency_s` is `840` and `reply_chars` is `13`. 8. Given the same pair with `reply_at` `2026-10-01T12:00:00Z`, when `OsMe::DecisionMoment.memo_reply` builds the moment, then `latency_s` is `nil`, `reply_chars` is `13`, `ai_at` is `2026-10-01T10:00:00Z`, and `reply_at` is `2026-10-01T12:00:00Z`. 9. Given a Store and a train session with one memo pair (the S2 fixture memo at `2026-10-01T10:00:00Z`, reply `yes, go ahead` at `2026-10-01T12:00:00Z`) and a fake `claude` on `PATH` that answers shape `accept`, when `os-me-extract --store <store> --transcript-root <root> --budget 20` runs, then it exits 0 and `moments.jsonl` holds one row with `kind` `memo_reply`, `latency_s` `null`, and `reply_chars` `13`. ## Owner sketch ```ruby module OsMe class MomentKind CRITERIA = { 'question_answer' => 'The AI asked a question or offered options without a full decision memo, and the reply answers that question.', 'correction' => 'The AI did or decided something on its own, and the reply says it was wrong and asks to undo or change it.', 'direction' => 'The AI did not ask for a ruling, and the reply gives a new instruction or chooses a direction for the work.', 'over_escalation' => 'The AI asked for a ruling, and the reply hands the decision back or says the answer was obvious, for example "you decide", "obviously", or "why ask me".', 'none' => 'The reply gives no ruling and no instruction: thanks, small talk, or a status question.' }.freeze KINDS = (CRITERIA.keys - ['none']).freeze INSTRUCTION = 'Classify the decision moment in this exchange between the AI message and the reply. Pick the one kind whose criterion fits best.' def initialize(client:) @client = client end def classify(pair) @client.ask({ 'ai' => pair.ai_text, 'reply' => pair.reply_text }, [question]).fetch(:kind).choice end private def question = Decision::Model::Question.choice(:kind, criteria: CRITERIA, instructions: INSTRUCTION) end class ActiveStretch IDLE_GAP_S = 900 def active?(latency) = latency.seconds <= IDLE_GAP_S def filter(latency) = active?(latency) ? latency : latency.with(seconds: nil) end end ``` ## Non-goals - Connecting `MomentKind` to `MomentExtractor`, the moment consumers, and budget counts for the new calls: S7b. - Tuning the `MomentKind` criteria. The S7 gate (an agent checks a sample of each kind; 80% agreement) is orchestrator work after S7b. - Changing rows that are already in `moments.jsonl`. - Demotion (S9). ## Origin - Trigger: User asked for the decision-system build tickets this session; S7 is split into S7a (this ticket) and S7b because it needs more than 10 acceptance criteria - Improvised this session: none - Chain: no ticket for this stage ← the build plan names the stage and the user asked for the tickets. Stop at `DESIGN` (`docs/decision-system/build-plan.md` S7). - Root candidate: none found; searched MomentKind, moment kinds, ActiveStretch, latency filter, os-me - Where: docs/decision-system/build-plan.md S7; docs/decision-system/extraction.md; plugins/os-me/lib/os_me/ - Session: 4f3f95ea-3c6f-4d23-a0ea-e349d6ab02a5 - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os--claude-worktrees-ticket-1250/4f3f95ea-3c6f-4d23-a0ea-e349d6ab02a5.jsonl VERDICT: CREATE (user-approved; backlog search only)
Author
Owner

Work started on branch ticket-1249 (os-sdlc implement, S7a).

Work started on branch ticket-1249 (os-sdlc implement, S7a).
Author
Owner

Resolution

Done: S7a: OsMe::MomentKind (gem choice question, four kinds plus none) and OsMe::ActiveStretch (900 s idle gap); DecisionMoment.memo_reply nulls latency_s outside an active stretch and keeps ai_at and reply_at

Evidence: implementation 137 merge-ready, 10 dispatches (one test-review fix round); suite and lint gates pass; commit on ticket-1249 touches moment_kind.rb, active_stretch.rb, decision_moment.rb and four tests

Follow-ups: S7b (wire MomentKind into the extractor, write the four kinds) filed after merge with a per-call budget ruling; S7 gate (80% agreement per kind) runs after S7b

## Resolution **Done:** S7a: OsMe::MomentKind (gem choice question, four kinds plus none) and OsMe::ActiveStretch (900 s idle gap); DecisionMoment.memo_reply nulls latency_s outside an active stretch and keeps ai_at and reply_at **Evidence:** implementation 137 merge-ready, 10 dispatches (one test-review fix round); suite and lint gates pass; commit on ticket-1249 touches moment_kind.rb, active_stretch.rb, decision_moment.rb and four tests **Follow-ups:** S7b (wire MomentKind into the extractor, write the four kinds) filed after merge with a per-call budget ruling; S7 gate (80% agreement per kind) runs after S7b
jared closed this issue 2026-10-23 19:11:06 +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#1249
No description provided.