Skip to content

Commit 3c3885e

Browse files
authored
feat(workflow): close milestone 3 router boundary (#18)
* feat(workflow): close milestone 3 router boundary * docs(release-log): link milestone 3 pr * fix(workflow): normalize routing decision contract --------- Co-authored-by: Hanna Rosengren <4538260+hannasoderstromdev@users.noreply.github.com>
1 parent 26e651d commit 3c3885e

12 files changed

Lines changed: 438 additions & 21 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. Dates are d
44

55
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
66

7+
#### Unreleased
8+
9+
- feat(workflow): close milestone 3 with contract-backed router and Claude evidence
10+
711
#### [v1.2.1](https://github.com/hannasdev/model-switchboard/compare/v1.2.0...v1.2.1)
812

913
- Complete Milestone 2 session-controller closeout [`#17`](https://github.com/hannasdev/model-switchboard/pull/17)

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ High-level flow:
2828
1. You send a prompt through Switchboard.
2929
2. Switchboard classifies the turn and selects a route label.
3030
3. Switchboard launches or resumes Claude with matching model and effort settings.
31-
4. Route context and hook evidence are recorded for explainability and governance.
31+
4. Route context, session state, and hook evidence are recorded for explainability, replay, and governance.
3232

3333
## What It Is Not
3434

@@ -49,7 +49,9 @@ High-level flow:
4949

5050
Version 1.0.0 is the first stable release of the prompt-driven Switchboard workflow for Claude Code.
5151

52-
Non-interactive continuity is verified for routed turns. Interactive continuity is verified for session reuse, resume semantics, hook correlation, and override handling, while stale-resume recovery and fail-closed error handling remain explicitly tracked as follow-up validation for fully supported interactive parity.
52+
Milestones 1 through 3 are now complete: router contracts, session-aware policy/controller boundaries, and the Claude workflow refit onto contract-backed router/session/context evidence.
53+
54+
Non-interactive continuity is verified for routed turns. Interactive continuity is verified for session reuse, resume semantics, hook correlation, stale-resume recovery, and fail-closed error handling.
5355

5456
## Delivery Model
5557

docs/ROUTER-PHASE-PLAN.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ Acceptance criteria:
154154

155155
### Milestone 3: Claude Workflow On Router Boundary
156156

157+
Status: complete (2026-05-11)
158+
159+
Decision record: see `DEC-2026-05-11-milestone-3-claude-workflow-boundary-closeout` in `docs/decision-log.md`.
160+
157161
Refit the existing Claude workflow so it consumes the router as a client integration rather than embedding router assumptions directly.
158162

159163
Required work:

docs/contracts/router-contracts.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,8 @@ Current workflow signals in the switchboard implementation map as follows:
326326
* current route mode -> `SessionState.mode`
327327
* `routingOverride` -> `SessionState.routingOverride`
328328

329+
Current workflow persistence now records a contract-backed `sessionState` object alongside compatibility fields in route-context storage.
330+
329331
### A.2 Target Mapping
330332

331333
Current route labels map to target classes in the Claude integration:
@@ -346,6 +348,8 @@ Current continuity semantics are:
346348

347349
These semantics belong to workflow execution behavior, not router decision semantics.
348350

351+
Current workflow evidence now separates router-decision data from Claude execution data in wrapper logs while preserving continuity semantics and stale-resume recovery behavior.
352+
349353
### A.4 Hook Correlation Mapping
350354

351355
Current hook evidence maps to `RoutingLogEvent.correlation`:
@@ -356,6 +360,8 @@ Current hook evidence maps to `RoutingLogEvent.correlation`:
356360

357361
Hook timing and correlation quality remain surface-specific and should not be treated as universal across clients.
358362

363+
Current route-context persistence also records a contract-backed `ContextPackage` and `claudeExecution` block so hook correlation can consume stable handoff fields without depending only on legacy flat fields.
364+
359365
## Appendix B: Open Questions
360366

361367
1. Should `riskLevel` be router-owned or adapter-provided in v0.2?

docs/decision-log.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,44 @@ Consequences:
203203
Follow-up:
204204
- Next review milestone: Milestone 3 plan-to-implementation checkpoint.
205205
- Linked artifacts (logs, fixtures, docs, PRs): src/router/router.js, src/router/session_controller.js, test/session-controller.test.js, test/router.test.js, docs/ROUTER-PHASE-PLAN.md
206+
207+
## Milestone 3 Closeout
208+
209+
Decision ID: DEC-2026-05-11-milestone-3-claude-workflow-boundary-closeout
210+
Related deferred item: Milestone 3 Claude workflow boundary refit
211+
Status: committed
212+
Date: 2026-05-11
213+
Owners: team
214+
215+
Context:
216+
- Milestone 3 required the Claude workflow to consume router contracts as a client integration, preserve continuity semantics, and separate router decision evidence from Claude execution evidence.
217+
218+
Options considered:
219+
- Option A: keep the existing workflow-specific route context format and explain shape, and defer contract-backed persistence until Milestone 4.
220+
- Option B: refit workflow persistence and explain now so Claude consumes and emits contract-aligned router/session/context shapes while keeping compatibility fields for existing consumers.
221+
222+
Tradeoffs:
223+
- Option A: smaller short-term diff, but leaves Milestone 3 structurally incomplete and pushes risk into Milestone 4.
224+
- Option B: slightly larger boundary refit now, but cleaner milestone ownership, stronger replay/explain foundation, and lower follow-on ambiguity.
225+
226+
Verification signal:
227+
- Expected signal from phase plan: Claude workflow remains functional; router can be exercised independently of Claude launch details; logs distinguish router and Claude execution data; handoff/context fields match the core contract.
228+
- Evidence observed:
229+
- `src/switchboard/workflow.js` now emits separated `router` and `claude` evidence blocks while retaining compatibility summaries.
230+
- `src/switchboard/route_context.js` now persists canonical `sessionState`, `routingDecision`, `contextPackage`, and `claudeExecution` fields.
231+
- `switchboard explain` now surfaces contract-backed router and Claude evidence.
232+
- Full suite passes (`npm test`) including new workflow and explain tests for contract-backed evidence.
233+
234+
Decision:
235+
- Chosen option: Option B.
236+
- Scope of commitment: Milestone 3 is formally complete as of 2026-05-11.
237+
- What remains intentionally deferred: routing log-event normalization across all surfaces, outcome attribution taxonomy, replay-oriented evaluation tooling, and second-surface validation.
238+
239+
Consequences:
240+
- Near-term implementation impact: Claude workflow is now a cleaner consumer of router outputs with a more explicit control-plane boundary.
241+
- Test and replay impact: route context and explain data now carry contract-backed session and handoff fields suitable for Milestone 4 normalization.
242+
- Migration impact: low; legacy route-context fields remain present for current hook correlation and explain consumers.
243+
244+
Follow-up:
245+
- Next review milestone: Milestone 4 plan-to-implementation checkpoint.
246+
- Linked artifacts (logs, fixtures, docs, PRs): src/switchboard/workflow.js, src/switchboard/route_context.js, src/switchboard/cli.js, src/switchboard/claude_hook_bridge.js, test/switchboard-workflow.test.js, test/switchboard-cli.test.js, docs/ROUTER-PHASE-PLAN.md

docs/release-log.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
## Unreleased
22

3+
### 2026-05-11 — Close Milestone 3 with contract-backed Claude workflow evidence
4+
5+
- What changed: Refit the Claude workflow so route-context persistence and explain output now carry contract-backed router decision, session state, and context-package evidence while separating router and Claude execution data.
6+
- Why it matters: Completes the Milestone 3 control-plane boundary by making Claude a cleaner consumer of router outputs and by preserving stable fields for explain, replay, and hook correlation.
7+
- Who is affected: Switchboard maintainers and contributors working on explainability, replay, and future non-Claude integrations.
8+
- Action needed: None.
9+
- PR: https://github.com/hannasdev/model-switchboard/pull/18
10+
311
### 2026-05-11 — Close Milestone 2 with explicit session-controller boundary
412

513
- What changed: Extracted deterministic session mode-transition ownership into a dedicated controller module, wired router mode resolution through that boundary, and added focused transition tests while preserving existing routing behavior.

src/switchboard/claude_hook_bridge.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,16 @@ function routeContextText(routeResult) {
4848
function correlatedRouteContextText(correlation) {
4949
const latest = correlation.context?.latest;
5050
if (!latest) return null;
51+
const contextPackage = latest.contextPackage || null;
52+
const claudeExecution = latest.claudeExecution || null;
5153
return [
5254
"Switchboard wrapper route for this Claude Code session:",
53-
`Thread: ${latest.threadId || "unknown"}`,
54-
`Target label: ${latest.routeLabel || "unknown"}`,
55-
`Claude model/effort: ${latest.model || "unknown"}/${latest.effort || "unknown"}`,
56-
latest.wrapperContext?.text ? `Summary: ${latest.wrapperContext.text}` : null
55+
`Thread: ${latest.threadId || contextPackage?.threadId || "unknown"}`,
56+
`Target label: ${latest.routeLabel || contextPackage?.routeLabel || "unknown"}`,
57+
`Claude model/effort: ${latest.model || claudeExecution?.model || "unknown"}/${latest.effort || claudeExecution?.effort || "unknown"}`,
58+
(latest.wrapperContext?.text || contextPackage?.wrapperContext?.text)
59+
? `Summary: ${latest.wrapperContext?.text || contextPackage?.wrapperContext?.text}`
60+
: null
5761
].filter(Boolean).join("\n");
5862
}
5963

src/switchboard/cli.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,24 @@ function printHumanExplain(explanation, stdout) {
6363
return;
6464
}
6565

66+
const routerDecision = explanation.routerEvidence?.routeDecision || explanation.routeDecision;
67+
const routingDecision = explanation.routerEvidence?.routingDecision || explanation.routingDecision;
68+
const claudeSelected = explanation.claudeEvidence?.selectedClaude || explanation.selectedClaude;
69+
const claudeExecution = explanation.claudeEvidence?.execution || explanation.execution;
70+
6671
stdout.write(`${explanation.wrapperContext?.text || "Switchboard: no wrapper context"}\n`);
6772
stdout.write(`Thread: ${explanation.threadId || "unknown"}\n`);
68-
stdout.write(`Claude session: ${explanation.selectedClaude?.sessionId || "unknown"}\n`);
69-
stdout.write(`Claude target: ${explanation.selectedClaude?.model || "unknown"}/${explanation.selectedClaude?.effort || "unknown"}\n`);
70-
stdout.write(`Route: ${explanation.routeDecision?.label || "unknown"} (${explanation.routeDecision?.mode || "unknown"})\n`);
71-
const escalation = explanation.routeDecision?.escalationPolicy;
73+
stdout.write(`Claude session: ${claudeSelected?.sessionId || "unknown"}\n`);
74+
stdout.write(`Claude target: ${claudeSelected?.model || "unknown"}/${claudeSelected?.effort || "unknown"}\n`);
75+
stdout.write(`Router route: ${routerDecision?.label || "unknown"} (${routerDecision?.mode || "unknown"})\n`);
76+
stdout.write(`Router status: ${routingDecision?.status || routerDecision?.status || "unknown"}\n`);
77+
const escalation = routerDecision?.escalationPolicy;
7278
if (escalation?.applied && Array.isArray(escalation.reasons) && escalation.reasons.length > 0) {
7379
stdout.write(`Escalation: ${escalation.reasons.join(",")}\n`);
7480
}
81+
if (claudeExecution?.status) {
82+
stdout.write(`Claude execution: ${claudeExecution.status}\n`);
83+
}
7584
stdout.write(`Route context: ${explanation.routeContext.status}\n`);
7685
stdout.write(`Hook events: ${explanation.hookEvents.length}\n`);
7786
for (const event of explanation.hookEvents) {

src/switchboard/route_context.js

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,59 @@ function writeStore(storePath, store) {
1919
fs.writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`, "utf8");
2020
}
2121

22+
function deriveLegacyTurnFields(context = {}) {
23+
const sessionState = context.sessionState || null;
24+
const routingDecision = context.routingDecision || null;
25+
const contextPackage = context.contextPackage || null;
26+
const claudeExecution = context.claudeExecution || null;
27+
28+
return {
29+
threadId:
30+
context.threadId ||
31+
sessionState?.threadId ||
32+
contextPackage?.threadId ||
33+
null,
34+
turnCount:
35+
context.turnCount ??
36+
sessionState?.turnCount ??
37+
contextPackage?.turnIndex ??
38+
null,
39+
routeLabel:
40+
context.routeLabel ||
41+
contextPackage?.routeLabel ||
42+
routingDecision?.selectedTarget?.label ||
43+
null,
44+
targetId:
45+
context.targetId ||
46+
contextPackage?.targetId ||
47+
routingDecision?.selectedTarget?.id ||
48+
sessionState?.currentTargetId ||
49+
null,
50+
model:
51+
context.model ||
52+
claudeExecution?.model ||
53+
null,
54+
effort:
55+
context.effort ||
56+
claudeExecution?.effort ||
57+
null,
58+
mode:
59+
context.mode ||
60+
routingDecision?.mode ||
61+
sessionState?.mode ||
62+
contextPackage?.mode ||
63+
null,
64+
executionMode:
65+
context.executionMode ||
66+
claudeExecution?.executionMode ||
67+
null,
68+
wrapperContext:
69+
context.wrapperContext ||
70+
contextPackage?.wrapperContext ||
71+
null
72+
};
73+
}
74+
2275
export function saveRouteContext({
2376
storePath = DEFAULT_ROUTE_CONTEXT_PATH,
2477
context
@@ -30,17 +83,22 @@ export function saveRouteContext({
3083

3184
const store = readStore(storePath);
3285
const existing = store[sessionId] || { turns: [] };
86+
const legacy = deriveLegacyTurnFields(context);
3387
const turn = {
34-
threadId: context.threadId || null,
88+
threadId: legacy.threadId,
3589
claudeSessionId: sessionId,
36-
turnCount: context.turnCount ?? null,
37-
routeLabel: context.routeLabel || null,
38-
targetId: context.targetId || null,
39-
model: context.model || null,
40-
effort: context.effort || null,
41-
mode: context.mode || null,
42-
executionMode: context.executionMode || null,
43-
wrapperContext: context.wrapperContext || null,
90+
turnCount: legacy.turnCount,
91+
routeLabel: legacy.routeLabel,
92+
targetId: legacy.targetId,
93+
model: legacy.model,
94+
effort: legacy.effort,
95+
mode: legacy.mode,
96+
executionMode: legacy.executionMode,
97+
wrapperContext: legacy.wrapperContext,
98+
sessionState: context.sessionState || null,
99+
routingDecision: context.routingDecision || null,
100+
contextPackage: context.contextPackage || null,
101+
claudeExecution: context.claudeExecution || null,
44102
updatedAt: new Date().toISOString()
45103
};
46104

0 commit comments

Comments
 (0)