Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions changes/2026-10-23-issue-62-model-selection/brainstorm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Brainstorm

> **Issue 62 model selection** · approaches compared before design. Not a final design, and not an acceptance.

## A. Call the decider inside matchRoute

Every ask, including `Two roles own this.`, would reach a model. `takeRole` and `chooseLead` would start calling one too. Existing router tests that expect those asks with no model would fail. A rules table inside the match would also invent a role ranking the issue does not name.

Rejected. The decider stays outside `matchRoute`.

## B. Store overrides on team.yaml

One file would hold bots and model slots. `TeamSchema` is strict, and the team tests reject unknown keys. Secrets would sit next to the team roster. The issue allows this file, and Q2 records why this change does not use it.

Rejected for this change. A later issue can add a team-file slot if the schema grows on purpose.

## C. A separate models.yaml, consulted only on "No rule matched."

`selectModel` is pure precedence. `decide` is the runtime, and only `routeRole` calls it, and only for that one reason, and only when the file exists. Rules return `human` and do not rank roles. Command and Ollama are the only model calls. Decision tokens sit in their own array so the existing report header and provider enum stay.

Chosen. It keeps today's route and `SDD_ROLE_MODEL` behavior when the file is absent, and it does not pull escalation, rooms, or the evidence gate into this change.

## Comparison

| | A | B | C |
|---|---|---|---|
| No models.yaml matches today | No | No | Yes |
| Two owners stay with the human | No | Yes | Yes |
| Team schema stays strict | Yes | No | Yes |
| Report header stays | Unspecified | Unspecified | Yes |
| Ollama stays behind config | Unspecified | Unspecified | Yes |
59 changes: 59 additions & 0 deletions changes/2026-10-23-issue-62-model-selection/clarify-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Clarify design

> **Issue 62 model selection** · architect seams checked against the reading. Not an acceptance.

## Q1. What is the metrics key for a decision call?

The issue says to log every decision call with tokens and cost. It does not name the JSON key. The key is `decisions`, beside `requests`. A rules decision writes no row. Command and Ollama rows store tokens, model, work type, kind, and provider. Dollars stay out of the file.

## Q2. Are the secret length and the probability tolerance part of the issue text?

No. They are proposal bounds so the check is testable. PEM (`-----BEGIN`), a `name=value` assignment, or a no-whitespace value of at least 32 characters that is not an env-var name fails `sdd team check`. Probabilities must sit in `[0, 1]` and sum to within `0.01` of `1`. An env-var name such as `SDD_ROLE_MODEL` stays legal.

## Q3. Does routeRole grow a required dependency argument?

No. The third argument is optional: `{ env, fetch, runCommand, now }`. Callers that omit it keep today's path when `models.yaml` is absent. When the file exists and the reason is `No rule matched.`, a missing `runCommand` or `fetch` resolves to human. Core does not read `process.env`.

## Q4. Does Ollama assume a default host?

No. `base_url_env` must name a variable, and that variable must be set in the env the CLI passes. There is no default URL and no socket in tests.

## Q5. Does a decision write need a file lock?

No. `decide` awaits each `recordDecisionMetric` before the next write. That matches `startPhase`, which can already drop a row on overlap. This change does not add a lock.

## Q6. What does the route decider see?

The state is a JSON string of `stage`, `artifact`, `skill`, and `id`, each null when absent. The question is exactly `No rule matched.` Options are the role names sorted with `localeCompare`, then `human` once. A kept role prints `role <pick>` and `reason Model <id> picked <pick>.` There is no rule line. Every human result of that path prints `Ask the human.` and `No rule matched.`

## Q7. Where do the token counts come from?

A command reply may include `input_tokens`, `cache_read_tokens`, `cache_write_tokens`, and `output_tokens`. Ollama uses `prompt_eval_count` and `eval_count`. When the reply JSON itself names token fields, those override the Ollama counters. A missing count stays null. A command model is null unless the reply names one. An Ollama model is the response model, or the configured name such as `qwen2.5:7b`. The choice id is not stored as a model.

## Q8. Which Ollama request is sent?

`POST {base}/api/chat`, with `stream: false` and a JSON object format that requires `pick` and `probs`. The user content is JSON of `{ state, question, options }`. There is no `/api/generate` and no default host. An invalid base URL is not called. A throw or a non-OK response is a call with null tokens and confidence 0. The function is `ollamaDecision`.

## Q9. Which change receives the row?

`recordDecisionMetric` uses the active change id and honors `metrics.enabled`, the same as `startPhase`. No active change, or a config that cannot be read, skips the write and still routes.

## Q10. What do `sdd models show` and `sdd models which` print when the file is bad?

A missing file exits 1 and prints `No models.yaml.` A parse error, a schema error, or a secret uses the `loadModels` message. `which` without a known work type prints `Work type is invalid.` and exits 1.

## Q11. What is a failed command, and when does it escalate?

A throw, a non-zero exit, or a non-JSON reply is confidence 0, recorded with null tokens. It escalates only when a minimum is set. A missing env name, a missing `runCommand`, or a missing `fetch` asks the human, does not escalate, and writes no row.

## Q12. How is the fallback judged when it has no minimum of its own?

The fallback uses its own `min_confidence` when it has one. Otherwise it uses the first model's minimum. A still-low or invalid fallback asks the human. A second failure does not call a third model. A direct call with no minimum keeps a valid probability of `0.01` and does not escalate an invalid reply. The sum check is `abs(sum - 1) > 0.01 + 1e-9`, so a true difference of `0.01` is kept and `1.02` is not.

## Q13. Where does `--bot` go?

`--bot` is an option on `sdd role route` only. `sdd role take` keeps its positional bot and stays model-free.

## Open

No design question remains for this change. These answers are a proposal. The person has not accepted the reading.
35 changes: 35 additions & 0 deletions changes/2026-10-23-issue-62-model-selection/clarify-intent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Clarify intent

> **Issue 62 model selection** · proposals from the research assistant, product manager, product owner, and architect. Not an acceptance.

## Q1. Does draft default to the comment's fast model or the issue's strong model?

The owner's comment example sets `draft: fast`. The issue says a strong model handles drafting. The reading follows the issue. `draft`, `design`, `code`, and `review` default to the strong reason model. The comment's `draft: fast` is not the default.

## Q2. Do overrides live in models.yaml or team.yaml?

The issue says `.sdd/models.yaml` or `team.yaml`. `TeamSchema` is strict, so a model map in `team.yaml` fails today's check. This change reads `.sdd/models.yaml` only. It does not add model keys to the team file and it does not weaken the schema. If both files set a slot, `models.yaml` is the only file that counts.

## Q3. When may the decider run?

Only inside `routeRole`, and only when `matchRoute` returns ask with the reason exactly `No rule matched.`, and only when `models.yaml` exists. A routing rule, one `owns` or `skills` hit, a stage owner, or `Two roles own this.` never calls a model. `matchRoute`, `takeRole`, and `chooseLead` stay model-free. A no-rule take still fails with ask and writes nothing.

## Q4. What happens below the confidence minimum?

Confidence is the probability of `pick`. A reply that fails the Zod check has confidence `0`. Below `min_confidence`, ask `fallback_reason` once in decide mode. If that reply is still low, or the command or fetch is missing, ask the human. No `min_confidence` means no escalation. The rules provider never reads confidence. A missing env, `runCommand`, or `fetch` is human. Core does not read `process.env`. The CLI passes env in.

## Q5. Where do decision tokens go?

Do not add `rules`, `command`, or `ollama` to the token-usage provider enum, and do not map them onto `grok`. Do not change the report header `source phase provider model requests input cache_read cache_write output usd inr`. Add a `decisions` array. When it is non-empty, the report prints a second section. Dollars stay computed by `report.ts` from `prices.yaml`. They are not stored in `metrics.json`.

## Q6. Is the Ollama preset in this change?

Yes, behind config, as the issue says it comes right after the rules and command providers. Tests inject `fetch`. There is no live server, no `ollama` CLI spawn, and no change to the `ollama run` launcher. Hosted System-One stays out.

## Q7. Which directory holds the new modules?

The owner's comment names `packages/core/src/team/`. That directory does not exist. The router is `packages/core/src/role/routing.ts`. New modules go under `packages/core/src/role/models/`.

## Open

No intent question remains for this change. The person has not accepted the reading.
47 changes: 47 additions & 0 deletions changes/2026-10-23-issue-62-model-selection/clarify-tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Clarify tasks

> **Issue 62 model selection** · task check against the design. Not an acceptance.

## Q1. Does any task need a behavior the design does not name?

No. The task list follows the seam table. `matchRoute`, `takeRole`, and `chooseLead` stay model-free. `decide` has one production caller, `routeRole`.

## Q2. Does the report task change the existing cost rows?

No. The token header stays. Decision rows are a second section, and only when `decisions` is non-empty. Dollars are computed at report time.

## Q3. Is the Ollama task a live server?

No. The task is an HTTP preset tested with an injected `fetch`. `launch-agent.ts` stays as it is.

## Q4. What is in the decide state?

The dev lead asked. The state is JSON of `stage`, `artifact`, `skill`, and `id`. The question text is exactly `No rule matched.`

## Q5. Which numbers are stored?

Command replies may name the four token fields. Ollama stores `prompt_eval_count` and `eval_count` unless the reply JSON names token fields, which then win. Missing stays null. The stored model is the reply's model, or the Ollama model name. It is not the choice id.

## Q6. Which HTTP call does the Ollama task make?

`POST {base}/api/chat` with `stream: false` and a JSON schema that requires `pick` and `probs`. No default host.

## Q7. When does the metrics task write?

It uses the active change and `metrics.enabled`. If either is missing, the route still returns and the file is left alone.

## Q8. What failure text do the command tasks print?

Missing file: `No models.yaml.` Bad work type: `Work type is invalid.` A secret or a bad file uses the loader message and does not print the secret.

## Q9. What does a broken command count as?

A throw, a non-zero exit, or non-JSON is confidence 0. It escalates only when a minimum is set. A missing command or fetch does not escalate.

## Q10. What minimum does the fallback task use?

The fallback's own minimum, or the first model's minimum when the fallback has none. A still-low strong reply asks the human. The sum tolerance keeps a difference of `0.01` and rejects `1.02`, using a `1e-9` epsilon so a binary `0.01` is not rejected.

## Open

No task question remains for this change. The dev lead's questions are answered here as a proposal. The person has not accepted the reading.
79 changes: 79 additions & 0 deletions changes/2026-10-23-issue-62-model-selection/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Design

> **Issue 62 model selection** · architect seams. A proposal. The person has not accepted the reading.

## Approach

Approach C from the brainstorm. Add model selection beside the router. `matchRoute` stays as it is. `routeRole` may call `decide` only after that function asks with the reason exactly `No rule matched.`, and only when `.sdd/models.yaml` loads. No file means today's ask text and no model spawn.

## Config

`.sdd/models.yaml` is the only config. `sdd team init` does not write it. `.sdd/models/` stays the embedding cache.

```yaml
version: 1
models:
rules: { kind: decide, provider: rules }
local: { kind: decide, provider: command, command_env: SDD_DECIDE_MODEL, min_confidence: 0.7 }
strong: { kind: reason, provider: command, command_env: SDD_ROLE_MODEL }
nearby: { kind: decide, provider: ollama, model: qwen2.5:7b, base_url_env: OLLAMA_HOST, min_confidence: 0.7 }
defaults:
by_work_type:
route: rules
classify: local
check: local
draft: strong
design: strong
code: strong
review: strong
fallback_reason: strong
fallback_decide: rules
overrides:
bots: { bob-dev: { code: strong } }
stages: { pr_review: { review: strong } }
roles: { architect: { review: strong } }
```

There is no `command:` field, so a shell string is not stored. `command_env` and `base_url_env` are names only. The schema is strict. A missing file returns null. Ids in defaults and overrides must exist, and the model's `kind` must match the slot: decide for route, classify, and check; reason for draft, design, code, and review.

Precedence inside one work type is `overrides.bots[bot]`, then `overrides.stages[stage]`, then `overrides.roles[role]`, then `defaults.by_work_type`, then the fallback for that kind. A missing work-type key uses `fallback_decide` for route, classify, and check, and `fallback_reason` for the strong kinds.

## Seams

| Piece | Change |
|---|---|
| `packages/core/src/role/types.ts` | Add `WorkType` and `DecisionModel` beside `RoleModel` |
| `packages/core/src/role/models/schema.ts` | `ModelsConfigSchema`, `loadModels`, `checkModels`. Strict. Missing file returns null |
| `packages/core/src/role/models/select.ts` | `selectModel` is pure precedence and returns id, kind, provider, and why |
| `packages/core/src/role/models/decide.ts` | `decide`. Tests call it. The only production caller is `routeRole` |
| `packages/core/src/role/models/command.ts` | `modelFromEnv(env)` moves here. Reason stdin stays `{ roleId, prompt }`. Decide stdin is `{ mode: "decide", state, question, options }` |
| `packages/core/src/role/models/rules.ts` | `rulesDecision` returns `human` when that option is present. No role table |
| `packages/core/src/role/models/ollama.ts` | `ollamaDecision({ baseUrl, model, fetch })` only when `provider: ollama`. `POST {base}/api/chat`. No default URL |
| `packages/core/src/role/routing.ts` | `routeRole(root, input, deps?)`. After `matchRoute`, call `decide` only for ask plus `No rule matched.` when `loadModels` is non-null. Options are sorted role names plus `human` |
| `packages/core/src/role/index.ts` | Export the new functions and types |
| `packages/cli/src/role-cmd.ts` | `modelFromEnv(process.env)`. Pass `env` into `routeRole` |
| `packages/cli/src/models-cmd.ts` | `sdd models show` and `sdd models which`. Register `models` in `commands.ts` |
| `packages/core/src/role/team.ts` and `packages/cli/src/team-cmd.ts` | `checkTeam` reads the team, then `checkModels`. `sdd team check` |
| `packages/core/src/metrics.ts` | Optional `decisions` array and `recordDecisionMetric`. Do not edit `importSessionMetrics` |
| `packages/core/src/report.ts` | Second section when `decisions` is non-empty. Cost from `prices.yaml`. Header unchanged |

`routeRole` deps are `{ env, fetch, runCommand, now }`. Existing callers omit them. Core does not read `process.env`. A missing env, `runCommand`, or `fetch` resolves to human.

A decide reply is accepted only when `pick` is one of `options`, every option has a probability in `[0, 1]`, and the probabilities sum to within `0.01` of `1`. The comparison is `abs(sum - 1) > 0.01 + 1e-9`. Otherwise confidence is `0`. Below `min_confidence`, call `fallback_reason` once with mode decide. The fallback uses its own minimum, or the first model's minimum when it has none. Still low resolves to human. A missing command or fetch resolves to human and does not escalate. A throw, a non-zero exit, or non-JSON is confidence 0 and escalates only when a minimum is set. Rules never read confidence. A direct call with no `min_confidence` does not escalate.

Secret check, proposal bounds: PEM (`-----BEGIN`), a `name=value` assignment, or a no-whitespace value of at least 32 characters that is not an env-var name. Env names such as `SDD_ROLE_MODEL` stay legal. Check that file's values only. Do not print the secret.

`docs/examples/role-model.mjs` stays a reason example. It returns `{ text }` and fails a decide check on purpose.

Host stubs in `agents.ts` are not lengthened. `takeRole` and `chooseLead` do not gain a model. Ordinary `runRole` kinds stay `reading`, `code`, `scenario`, and `release`.

## Report

Decision rows are separate from `requests`. Suggested row fields: `workType`, `model` (nullable), `kind` (`reason` or `decide`), `provider` (`rules`, `command`, or `ollama`), token counts, and `startedAt`. Rules rows are not written. Dollars are computed at report time. `importSessionMetrics` writes the parsed object back, so a round-trip must keep `decisions`. An old file with no `decisions` key still parses.

## Risks

1. The decision provider cannot go on `TokenUsageMetricSchema.provider`. A separate array avoids a false provider and keeps the cost rows. The issue never named the key `decisions`.
2. A rules table would be invented. `rulesDecision` returns human and does not rank roles. `decide` is not inside `matchRoute`.
3. `role-model.mjs` will not satisfy the decide check. One fallback, then human. Point `SDD_DECIDE_MODEL` at a decide command. Do not change the example.
4. `metrics.json` is read-modify-write and can drop a row, which is already true of `startPhase`. `decide` awaits each `recordDecisionMetric` before the next write and does not add a lock. The secret length `32` and the sum tolerance `0.01` are proposal bounds.
Loading
Loading