openhermes 4.9.2 → 4.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CONTEXT.md +7 -7
  2. package/ETHOS.md +2 -2
  3. package/README.md +34 -33
  4. package/bootstrap.ts +310 -160
  5. package/harness/agents/oh-planner.md +1 -1
  6. package/harness/agents/openhermes.md +27 -126
  7. package/harness/codex/AUTOPILOT.md +131 -23
  8. package/harness/codex/CHARTER.md +4 -5
  9. package/harness/lib/background/background.test.ts +216 -0
  10. package/harness/lib/background/index.ts +7 -0
  11. package/harness/lib/background/interfaces.ts +31 -0
  12. package/harness/lib/background/manager.ts +320 -0
  13. package/harness/lib/composer/compose.test.ts +179 -0
  14. package/harness/lib/composer/compose.ts +65 -0
  15. package/harness/lib/composer/fragments/01-identity.md +1 -0
  16. package/harness/lib/composer/fragments/02-delegation.md +7 -0
  17. package/harness/lib/composer/fragments/03-permissions.md +13 -0
  18. package/harness/lib/composer/fragments/04-task-flow.md +55 -0
  19. package/harness/lib/composer/fragments/05-confidence.md +5 -0
  20. package/harness/lib/composer/fragments/06-parallelization.md +17 -0
  21. package/harness/lib/composer/fragments/07-shell.md +41 -0
  22. package/harness/lib/composer/fragments/08-routing.md +8 -0
  23. package/harness/lib/composer/fragments/09-guardrails.md +25 -0
  24. package/harness/lib/composer/index.ts +1 -0
  25. package/harness/lib/guards/guard-config.ts +72 -0
  26. package/harness/lib/hooks/builtins/confidence-gate-hook.ts +68 -0
  27. package/harness/lib/hooks/builtins/delegation-depth-hook.ts +78 -0
  28. package/harness/lib/hooks/builtins/dynamic-route-hook.ts +99 -0
  29. package/harness/lib/hooks/builtins/error-recovery-hook.ts +107 -0
  30. package/harness/lib/hooks/builtins/memory-sync-hook.ts +73 -0
  31. package/harness/lib/hooks/builtins/next-route-hook.ts +24 -0
  32. package/harness/lib/hooks/builtins/plan-check-hook.ts +43 -0
  33. package/harness/lib/hooks/builtins/route-tracking-hook.ts +201 -0
  34. package/harness/lib/hooks/builtins/sanity-check-hook.ts +52 -0
  35. package/harness/lib/hooks/builtins/shell-detect-hook.ts +96 -0
  36. package/harness/lib/hooks/builtins/subagent-failure-hook.ts +93 -0
  37. package/harness/lib/hooks/hooks.test.ts +1092 -0
  38. package/harness/lib/hooks/index.ts +42 -0
  39. package/harness/lib/hooks/registry.ts +416 -0
  40. package/harness/lib/hooks/types.ts +119 -0
  41. package/harness/lib/memory/index.ts +18 -0
  42. package/harness/lib/memory/interfaces.ts +53 -0
  43. package/harness/lib/memory/memory-manager.ts +205 -0
  44. package/harness/lib/memory/memory.test.ts +485 -0
  45. package/harness/lib/memory/plan-store.ts +346 -0
  46. package/harness/lib/plans/plan-location.ts +134 -0
  47. package/harness/lib/recovery/handler.ts +243 -0
  48. package/harness/lib/recovery/index.ts +14 -0
  49. package/harness/lib/recovery/interfaces.ts +48 -0
  50. package/harness/lib/recovery/patterns.ts +149 -0
  51. package/harness/lib/recovery/recovery.test.ts +312 -0
  52. package/harness/lib/routing/index.ts +21 -0
  53. package/harness/lib/routing/route-guidance.ts +147 -0
  54. package/harness/lib/routing/route-resolver.ts +58 -0
  55. package/harness/lib/routing/routing.test.ts +195 -0
  56. package/harness/lib/routing/skill-frontmatter.ts +125 -0
  57. package/harness/lib/routing/types.ts +52 -0
  58. package/harness/lib/sanity/anomaly-tracker.ts +127 -0
  59. package/harness/lib/sanity/checker.ts +189 -0
  60. package/harness/lib/sanity/index.ts +13 -0
  61. package/harness/lib/sanity/interfaces.ts +24 -0
  62. package/harness/lib/sanity/sanity.test.ts +472 -0
  63. package/harness/lib/sync/file-watcher.ts +175 -0
  64. package/harness/lib/sync/index.ts +11 -0
  65. package/harness/lib/sync/interfaces.ts +27 -0
  66. package/harness/lib/sync/plan-sync.ts +533 -0
  67. package/harness/lib/sync/sync.test.ts +858 -0
  68. package/harness/skills/oh-fusion/DEEP.md +109 -86
  69. package/harness/skills/oh-fusion/SKILL.md +47 -33
  70. package/harness/skills/oh-init/DEEP.md +2 -2
  71. package/harness/skills/oh-manifest/SKILL.md +2 -1
  72. package/harness/skills/oh-plan-review/DEEP.md +1 -1
  73. package/harness/skills/oh-planner/DEEP.md +3 -3
  74. package/harness/skills/oh-review/DEEP.md +5 -3
  75. package/harness/skills/oh-review/SKILL.md +1 -0
  76. package/harness/skills/oh-ship/SKILL.md +1 -1
  77. package/harness/skills/oh-skill-craft/SKILL.md +1 -4
  78. package/package.json +53 -55
  79. package/tsconfig.json +1 -1
  80. package/harness/commands/oh-doctor.md +0 -205
  81. package/harness/commands/oh-log.md +0 -18
  82. package/harness/skills/oh-learn/DEEP.md +0 -44
  83. package/harness/skills/oh-learn/SKILL.md +0 -30
  84. package/scripts/count-tokens.mjs +0 -158
  85. package/scripts/oh-doctor.ps1 +0 -342
@@ -0,0 +1,312 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { RecoveryHandler } from "./handler.ts";
4
+ import type { ErrorContext, RecoveryAction } from "./interfaces.ts";
5
+
6
+ /**
7
+ * Helper to build an ErrorContext.
8
+ */
9
+ function ctx(
10
+ sessionId: string,
11
+ message: string,
12
+ attempt = 0,
13
+ agent?: string,
14
+ ): ErrorContext {
15
+ return {
16
+ sessionId,
17
+ error: new Error(message),
18
+ attempt,
19
+ timestamp: Date.now(),
20
+ agent,
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Check that an action has the expected type & reason substring.
26
+ */
27
+ function assertAction(
28
+ action: RecoveryAction,
29
+ type: string,
30
+ reasonSubstring?: string,
31
+ ): void {
32
+ assert.equal(action.type, type, `expected type ${type}, got ${action.type}`);
33
+ if (reasonSubstring) {
34
+ assert.ok(
35
+ action.reason.toLowerCase().includes(reasonSubstring.toLowerCase()),
36
+ `expected reason to include "${reasonSubstring}", got "${action.reason}"`,
37
+ );
38
+ }
39
+ }
40
+
41
+ // ── Helpers ──────────────────────────────────────────────────────────
42
+
43
+ describe("RecoveryHandler — pattern classification", () => {
44
+ let handler: RecoveryHandler;
45
+
46
+ before(() => {
47
+ handler = RecoveryHandler.getInstance();
48
+ handler.reset();
49
+ });
50
+
51
+ after(() => {
52
+ handler.reset();
53
+ });
54
+
55
+ // ── 9 category tests ─────────────────────────────────────────────
56
+
57
+ it("classifies rate_limit errors → retry with backoff", () => {
58
+ const action = handler.handleError(ctx("s1", "rate limit exceeded", 0));
59
+ assertAction(action, "retry", "rate");
60
+ assert.ok(action.delay! >= 1_000, `expected delay >= 1000, got ${action.delay}`);
61
+
62
+ // Second attempt gets longer backoff
63
+ const action2 = handler.handleError(ctx("s1", "429 Too Many Requests", 1));
64
+ assertAction(action2, "retry", "rate");
65
+ assert.ok(action2.delay! >= 2_000, `expected delay >= 2000, got ${action2.delay}`);
66
+ });
67
+
68
+ it("classifies context_overflow errors → compact", () => {
69
+ const action = handler.handleError(ctx("s1", "context length exceeded", 0));
70
+ assertAction(action, "compact", "context");
71
+ assert.ok(action.modifyPrompt, "compact action should include modifyPrompt");
72
+ });
73
+
74
+ it("classifies network errors → retry with backoff", () => {
75
+ const action = handler.handleError(ctx("s1", "ECONNREFUSED", 0));
76
+ assertAction(action, "retry", "network");
77
+ assert.ok(action.delay! >= 500, `expected delay >= 500, got ${action.delay}`);
78
+
79
+ const action2 = handler.handleError(ctx("s1", "fetch failed", 1));
80
+ assertAction(action2, "retry", "network");
81
+ });
82
+
83
+ it("classifies session errors → abort", () => {
84
+ const action = handler.handleError(ctx("s1", "session not found", 0));
85
+ assertAction(action, "abort", "session");
86
+ });
87
+
88
+ it("classifies tool_error → escalate", () => {
89
+ const action = handler.handleError(ctx("s1", "unknown tool: foo", 0));
90
+ assertAction(action, "escalate", "tool");
91
+ });
92
+
93
+ it("classifies parse_error → retry (max 2)", () => {
94
+ const action = handler.handleError(ctx("s1", "parse error at line 42", 0));
95
+ assertAction(action, "retry", "parse");
96
+ assert.equal(action.maxAttempts, 2);
97
+ });
98
+
99
+ it("classifies gibberish (explicit) → retry with clean context", () => {
100
+ // Gibberish pattern now matches "gibberish", "nonsens", keyboard mash,
101
+ // and other low-quality output patterns in error messages.
102
+ const action = handler.handleError(ctx("s1", "gibberish output detected", 0));
103
+ assertAction(action, "retry", "gibberish");
104
+ assert.ok(action.modifyPrompt, "gibberish action should include modifyPrompt");
105
+ });
106
+
107
+ it("classifies lsp_diagnostic → retry with diagnostic prompt", () => {
108
+ const action = handler.handleError(ctx("s1", "tsc error: Type 'X' not assignable", 0));
109
+ assertAction(action, "retry", "lsp");
110
+ assert.ok(action.modifyPrompt, "lsp action should include modifyPrompt");
111
+ });
112
+
113
+ it("classifies timeout → retry with breakdown hint", () => {
114
+ const action = handler.handleError(ctx("s1", "execution timed out", 0));
115
+ assertAction(action, "retry", "timed out");
116
+ assert.ok(action.modifyPrompt, "timeout action should include modifyPrompt");
117
+ assert.equal(action.maxAttempts, 2);
118
+ });
119
+
120
+ // ── Unknown error → escalate ──────────────────────────────────────
121
+
122
+ it("unknown error pattern → escalate", () => {
123
+ const action = handler.handleError(ctx("s1", "some weird error nobody expected", 0));
124
+ assertAction(action, "escalate", "unrecognized");
125
+ });
126
+
127
+ // ── Stats tracking ────────────────────────────────────────────────
128
+
129
+ it("getStats() reflects classified errors", () => {
130
+ handler.reset();
131
+
132
+ handler.handleError(ctx("s1", "rate limit", 0));
133
+ handler.handleError(ctx("s1", "ECONNREFUSED", 0));
134
+ handler.handleError(ctx("s1", "context length", 0));
135
+ handler.handleError(ctx("s1", "session not found", 0));
136
+ handler.handleError(ctx("s1", "unknown tool", 0));
137
+ handler.handleError(ctx("s1", "parse error", 0));
138
+
139
+ const stats = handler.getStats();
140
+ assert.equal(stats.totalRecoveries, 6);
141
+ assert.equal(stats.byCategory.rate_limit, 1);
142
+ assert.equal(stats.byCategory.network, 1);
143
+ assert.equal(stats.byCategory.context_overflow, 1);
144
+ assert.equal(stats.byCategory.session, 1);
145
+ assert.equal(stats.byCategory.tool_error, 1);
146
+ assert.equal(stats.byCategory.parse_error, 1);
147
+ // Unclassified categories should be 0
148
+ assert.equal(stats.byCategory.gibberish, 0);
149
+ assert.equal(stats.byCategory.lsp_diagnostic, 0);
150
+ assert.equal(stats.byCategory.timeout, 0);
151
+ });
152
+
153
+ it("getStats() tracks action types", () => {
154
+ handler.reset();
155
+
156
+ handler.handleError(ctx("s1", "rate limit", 0)); // retry
157
+ handler.handleError(ctx("s1", "session expired", 0)); // abort
158
+ handler.handleError(ctx("s1", "unknown tool", 0)); // escalate
159
+ handler.handleError(ctx("s1", "context length", 0)); // compact
160
+
161
+ const stats = handler.getStats();
162
+ assert.equal(stats.byAction.retry, 1);
163
+ assert.equal(stats.byAction.abort, 1);
164
+ assert.equal(stats.byAction.escalate, 1);
165
+ assert.equal(stats.byAction.compact, 1);
166
+ });
167
+
168
+ // ── getHistory ──────────────────────────────────────────────────
169
+
170
+ it("getHistory() returns records most recent first", () => {
171
+ handler.reset();
172
+
173
+ handler.handleError(ctx("s2", "rate limit", 0));
174
+ handler.handleError(ctx("s2", "timeout", 0));
175
+
176
+ const history = handler.getHistory();
177
+ assert.equal(history.length, 2);
178
+ assert.ok(history[0].timestamp >= history[1].timestamp);
179
+ });
180
+
181
+ it("getHistory(limit) respects limit", () => {
182
+ handler.reset();
183
+
184
+ handler.handleError(ctx("s3", "rate limit", 0));
185
+ handler.handleError(ctx("s3", "timeout", 0));
186
+ handler.handleError(ctx("s3", "ECONNREFUSED", 0));
187
+
188
+ const limited = handler.getHistory(2);
189
+ assert.equal(limited.length, 2);
190
+ });
191
+
192
+ // ── clearSession ────────────────────────────────────────────────
193
+
194
+ it("clearSession() removes records for a session", () => {
195
+ handler.reset();
196
+
197
+ handler.handleError(ctx("s_a", "rate limit", 0));
198
+ handler.handleError(ctx("s_b", "timeout", 0));
199
+ handler.handleError(ctx("s_a", "ECONNREFUSED", 0));
200
+
201
+ assert.equal(handler.getHistory().length, 3);
202
+
203
+ handler.clearSession("s_a");
204
+ const remaining = handler.getHistory();
205
+ assert.equal(remaining.length, 1);
206
+ assert.equal(remaining[0].context.sessionId, "s_b");
207
+ });
208
+ });
209
+
210
+ // ── withRecovery integration ──────────────────────────────────────────
211
+
212
+ describe("RecoveryHandler — withRecovery", () => {
213
+ let handler: RecoveryHandler;
214
+
215
+ before(() => {
216
+ handler = RecoveryHandler.getInstance();
217
+ handler.reset();
218
+ });
219
+
220
+ after(() => {
221
+ handler.reset();
222
+ });
223
+
224
+ it("succeeds on first attempt", async () => {
225
+ const result = await handler.withRecovery("wr1", async () => "hello");
226
+ assert.equal(result, "hello");
227
+ });
228
+
229
+ it("retries on retryable error then succeeds", async () => {
230
+ handler.reset();
231
+ let callCount = 0;
232
+
233
+ const result = await handler.withRecovery("wr2", async () => {
234
+ callCount++;
235
+ if (callCount === 1) throw new Error("parse error: invalid json");
236
+ return "ok-after-retry";
237
+ });
238
+
239
+ assert.equal(result, "ok-after-retry");
240
+ assert.equal(callCount, 2);
241
+ });
242
+
243
+ it("throws on abort action", async () => {
244
+ handler.reset();
245
+
246
+ await assert.rejects(
247
+ handler.withRecovery("wr3", async () => {
248
+ throw new Error("session not found");
249
+ }),
250
+ /session not found/,
251
+ );
252
+ });
253
+
254
+ it("throws on escalate action", async () => {
255
+ handler.reset();
256
+
257
+ await assert.rejects(
258
+ handler.withRecovery("wr4", async () => {
259
+ throw new Error("unknown tool: foo");
260
+ }),
261
+ /unknown tool/,
262
+ );
263
+ });
264
+
265
+ it("respects category maxAttempts and then throws", async () => {
266
+ handler.reset();
267
+
268
+ await assert.rejects(
269
+ handler.withRecovery("wr5", async () => {
270
+ throw new Error("parse error: bad json");
271
+ }),
272
+ /parse error/,
273
+ );
274
+ });
275
+
276
+ it("respects global maxAttempts option", async () => {
277
+ handler.reset();
278
+ let callCount = 0;
279
+
280
+ // Use an unrecognized error (will escalate on first try — so throw immediately).
281
+ // Instead let's use a timeout pattern which has maxAttempts=2, but set global maxAttempts=1
282
+ await assert.rejects(
283
+ handler.withRecovery(
284
+ "wr6",
285
+ async () => {
286
+ callCount++;
287
+ throw new Error("execution timed out");
288
+ },
289
+ { maxAttempts: 1 },
290
+ ),
291
+ );
292
+ // With maxAttempts=1, it should only be called once
293
+ assert.equal(callCount, 1);
294
+ });
295
+
296
+ it("tracks success and failure in stats", async () => {
297
+ handler.reset();
298
+
299
+ // One success
300
+ await handler.withRecovery("wr7", async () => "good");
301
+ // One abort (failure)
302
+ await assert.rejects(
303
+ handler.withRecovery("wr7", async () => {
304
+ throw new Error("session expired");
305
+ }),
306
+ );
307
+
308
+ const stats = handler.getStats();
309
+ assert.ok(stats.successRate > 0);
310
+ assert.ok(stats.successRate < 1);
311
+ });
312
+ });
@@ -0,0 +1,21 @@
1
+ export { extractFrontmatter, parseSkillFrontmatter, readSkillFrontmatter, emptySkillRoutes } from "./skill-frontmatter.ts";
2
+ export { resolveRoute } from "./route-resolver.ts";
3
+ export {
4
+ clearRuntimeRouteDecision,
5
+ consumeRouteGuidance,
6
+ extractRouteGuidance,
7
+ extractRuntimeRouteDecision,
8
+ getRuntimeRouteDecision,
9
+ NEXT_ROUTE_PREFIX,
10
+ rememberRuntimeRouteDecision,
11
+ ROUTE_GUIDANCE_PREFIX,
12
+ } from "./route-guidance.ts";
13
+ export { ROUTE_OUTCOMES } from "./types.ts";
14
+ export type {
15
+ RouteEvidence,
16
+ RouteOutcome,
17
+ RouteResolution,
18
+ RuntimeRouteDecision,
19
+ SkillRouteMap,
20
+ SkillRoutingFrontmatter,
21
+ } from "./types.ts";
@@ -0,0 +1,147 @@
1
+ import {
2
+ ROUTE_ACTIONS,
3
+ ROUTE_OUTCOMES,
4
+ ROUTE_VERIFICATIONS,
5
+ ROUTE_WORK_TYPES,
6
+ } from "./types.ts";
7
+ import type {
8
+ RouteAction,
9
+ RouteOutcome,
10
+ RouteResolution,
11
+ RouteVerification,
12
+ RouteWork,
13
+ RuntimeRouteDecision,
14
+ } from "./types.ts";
15
+
16
+ export const ROUTE_GUIDANCE_PREFIX = "ROUTE_GUIDANCE:";
17
+ export const NEXT_ROUTE_PREFIX = "NEXT_ROUTE:";
18
+
19
+ interface ConsumedRouteGuidance {
20
+ output: string;
21
+ selected: string | null;
22
+ }
23
+
24
+ const runtimeRouteState = new Map<string, RuntimeRouteDecision>();
25
+
26
+ function isRouteOutcome(value: unknown): value is RouteOutcome {
27
+ return typeof value === "string" && ROUTE_OUTCOMES.includes(value as RouteOutcome);
28
+ }
29
+
30
+ function isRouteVerification(value: unknown): value is RouteVerification {
31
+ return typeof value === "string" && ROUTE_VERIFICATIONS.includes(value as RouteVerification);
32
+ }
33
+
34
+ function isRouteAction(value: unknown): value is RouteAction {
35
+ return typeof value === "string" && ROUTE_ACTIONS.includes(value as RouteAction);
36
+ }
37
+
38
+ function isRouteWork(value: unknown): value is RouteWork {
39
+ return typeof value === "string" && ROUTE_WORK_TYPES.includes(value as RouteWork);
40
+ }
41
+
42
+ export function extractRouteGuidance(output: string): RouteResolution | null {
43
+ const guidanceLine = output
44
+ .split(/\r?\n/)
45
+ .map((line) => line.trim())
46
+ .find((line) => line.startsWith(ROUTE_GUIDANCE_PREFIX));
47
+
48
+ if (!guidanceLine) return null;
49
+
50
+ const raw = guidanceLine.slice(ROUTE_GUIDANCE_PREFIX.length).trim();
51
+ if (!raw) return null;
52
+
53
+ try {
54
+ const parsed = JSON.parse(raw) as Partial<RouteResolution>;
55
+ if (!isRouteOutcome(parsed.outcome)) return null;
56
+ if (parsed.verification !== undefined && !isRouteVerification(parsed.verification)) return null;
57
+ if (parsed.action !== undefined && !isRouteAction(parsed.action)) return null;
58
+ if (parsed.work !== undefined && !isRouteWork(parsed.work)) return null;
59
+ if (!Array.isArray(parsed.candidates) || !parsed.candidates.every((candidate) => typeof candidate === "string")) {
60
+ return null;
61
+ }
62
+ if (parsed.selected !== null && parsed.selected !== undefined && typeof parsed.selected !== "string") {
63
+ return null;
64
+ }
65
+ if (typeof parsed.reason !== "string") return null;
66
+
67
+ return {
68
+ outcome: parsed.outcome,
69
+ ...(parsed.verification ? { verification: parsed.verification } : {}),
70
+ ...(parsed.action ? { action: parsed.action } : {}),
71
+ ...(parsed.work ? { work: parsed.work } : {}),
72
+ candidates: parsed.candidates,
73
+ selected: parsed.selected ?? null,
74
+ reason: parsed.reason,
75
+ };
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ export function consumeRouteGuidance(output: string): ConsumedRouteGuidance {
82
+ const guidance = extractRouteGuidance(output);
83
+ if (!guidance?.selected || output.includes(NEXT_ROUTE_PREFIX)) {
84
+ return { output, selected: guidance?.selected ?? null };
85
+ }
86
+
87
+ return {
88
+ output: `${output.trimEnd()}\n${NEXT_ROUTE_PREFIX} ${guidance.selected}`,
89
+ selected: guidance.selected,
90
+ };
91
+ }
92
+
93
+ function extractExplicitNextRoute(output: string): string | null {
94
+ const routeLine = output
95
+ .split(/\r?\n/)
96
+ .map((line) => line.trim())
97
+ .find((line) => line.startsWith(NEXT_ROUTE_PREFIX));
98
+
99
+ if (!routeLine) return null;
100
+
101
+ const raw = routeLine.slice(NEXT_ROUTE_PREFIX.length).trim();
102
+ if (!raw || /\s/.test(raw)) return null;
103
+ return raw;
104
+ }
105
+
106
+ export function extractRuntimeRouteDecision(output: string): RuntimeRouteDecision | null {
107
+ const explicitNextRoute = extractExplicitNextRoute(output);
108
+ if (explicitNextRoute) {
109
+ return {
110
+ selected: explicitNextRoute,
111
+ source: "next_route",
112
+ };
113
+ }
114
+
115
+ const guidance = extractRouteGuidance(output);
116
+ if (!guidance?.selected) return null;
117
+
118
+ return {
119
+ selected: guidance.selected,
120
+ source: "route_guidance",
121
+ outcome: guidance.outcome,
122
+ verification: guidance.verification,
123
+ action: guidance.action,
124
+ work: guidance.work,
125
+ candidates: guidance.candidates,
126
+ reason: guidance.reason,
127
+ };
128
+ }
129
+
130
+ export function rememberRuntimeRouteDecision(sessionId: string, output: string): RuntimeRouteDecision | null {
131
+ const decision = extractRuntimeRouteDecision(output);
132
+ if (!decision) {
133
+ runtimeRouteState.delete(sessionId);
134
+ return null;
135
+ }
136
+
137
+ runtimeRouteState.set(sessionId, decision);
138
+ return decision;
139
+ }
140
+
141
+ export function getRuntimeRouteDecision(sessionId: string): RuntimeRouteDecision | null {
142
+ return runtimeRouteState.get(sessionId) ?? null;
143
+ }
144
+
145
+ export function clearRuntimeRouteDecision(sessionId: string): void {
146
+ runtimeRouteState.delete(sessionId);
147
+ }
@@ -0,0 +1,58 @@
1
+ import type { RouteEvidence, RouteResolution, SkillRouteMap } from "./types.ts";
2
+
3
+ function findCandidate(candidates: string[], fragment: string): string | null {
4
+ return candidates.find((candidate) => candidate.includes(fragment)) ?? null;
5
+ }
6
+
7
+ function buildResolution(evidence: RouteEvidence, candidates: string[], selected: string | null, reason: string): RouteResolution {
8
+ return {
9
+ outcome: evidence.outcome,
10
+ ...(evidence.verification ? { verification: evidence.verification } : {}),
11
+ ...(evidence.action ? { action: evidence.action } : {}),
12
+ ...(evidence.work ? { work: evidence.work } : {}),
13
+ candidates,
14
+ selected,
15
+ reason,
16
+ };
17
+ }
18
+
19
+ export function resolveRoute(routeMap: SkillRouteMap, evidence: RouteEvidence): RouteResolution {
20
+ const candidates = [...routeMap[evidence.outcome]];
21
+ if (candidates.length === 0) {
22
+ return buildResolution(evidence, candidates, null, `No route candidates declared for outcome \"${evidence.outcome}\".`);
23
+ }
24
+
25
+ if (evidence.target && candidates.includes(evidence.target)) {
26
+ return buildResolution(evidence, candidates, evidence.target, `Selected \"${evidence.target}\" from output evidence.`);
27
+ }
28
+
29
+ if (evidence.action === "fixable" || evidence.work === "implement") {
30
+ const builderCandidate = findCandidate(candidates, "builder");
31
+ if (builderCandidate) {
32
+ return buildResolution(evidence, candidates, builderCandidate, `Selected \"${builderCandidate}\" for fixable implementation work.`);
33
+ }
34
+ }
35
+
36
+ if (evidence.verification === "unverified") {
37
+ const gauntletCandidate = findCandidate(candidates, "gauntlet");
38
+ if (gauntletCandidate) {
39
+ return buildResolution(evidence, candidates, gauntletCandidate, `Selected \"${gauntletCandidate}\" because work is still unverified.`);
40
+ }
41
+ }
42
+
43
+ if (evidence.work === "verify") {
44
+ const gauntletCandidate = findCandidate(candidates, "gauntlet");
45
+ if (gauntletCandidate) {
46
+ return buildResolution(evidence, candidates, gauntletCandidate, `Selected \"${gauntletCandidate}\" for verification work.`);
47
+ }
48
+ }
49
+
50
+ if (evidence.work === "ship" && evidence.verification === "verified" && evidence.action === "done") {
51
+ const shipCandidate = findCandidate(candidates, "ship");
52
+ if (shipCandidate) {
53
+ return buildResolution(evidence, candidates, shipCandidate, `Selected \"${shipCandidate}\" for verified ship-ready work.`);
54
+ }
55
+ }
56
+
57
+ return buildResolution(evidence, candidates, candidates[0], `Selected first declared route for outcome \"${evidence.outcome}\".`);
58
+ }