openhermes 4.11.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.
- package/CONTEXT.md +6 -6
- package/ETHOS.md +2 -2
- package/README.md +8 -8
- package/bootstrap.ts +131 -198
- package/harness/codex/AUTOPILOT.md +39 -27
- package/harness/codex/CHARTER.md +1 -1
- package/harness/lib/background/background.test.ts +24 -5
- package/harness/lib/background/manager.ts +9 -9
- package/harness/lib/composer/compose.test.ts +29 -18
- package/harness/lib/composer/fragments/02-delegation.md +5 -4
- package/harness/lib/composer/fragments/04-task-flow.md +43 -3
- package/harness/lib/composer/fragments/09-guardrails.md +25 -12
- package/harness/lib/guards/guard-config.ts +72 -0
- package/harness/lib/hooks/builtins/confidence-gate-hook.ts +9 -11
- package/harness/lib/hooks/builtins/delegation-depth-hook.ts +24 -5
- package/harness/lib/hooks/builtins/dynamic-route-hook.ts +99 -0
- package/harness/lib/hooks/builtins/error-recovery-hook.ts +7 -7
- package/harness/lib/hooks/builtins/memory-sync-hook.ts +2 -2
- package/harness/lib/hooks/builtins/next-route-hook.ts +24 -0
- package/harness/lib/hooks/builtins/plan-check-hook.ts +5 -5
- package/harness/lib/hooks/builtins/route-tracking-hook.ts +80 -26
- package/harness/lib/hooks/builtins/subagent-failure-hook.ts +93 -0
- package/harness/lib/hooks/hooks.test.ts +145 -69
- package/harness/lib/hooks/index.ts +12 -0
- package/harness/lib/hooks/registry.ts +3 -3
- package/harness/lib/hooks/types.ts +50 -2
- package/harness/lib/memory/memory-manager.ts +2 -2
- package/harness/lib/memory/memory.test.ts +0 -6
- package/harness/lib/memory/plan-store.ts +1 -21
- package/harness/lib/plans/plan-location.ts +134 -0
- package/harness/lib/routing/index.ts +21 -0
- package/harness/lib/routing/route-guidance.ts +147 -0
- package/harness/lib/routing/route-resolver.ts +58 -0
- package/harness/lib/routing/routing.test.ts +195 -0
- package/harness/lib/routing/skill-frontmatter.ts +125 -0
- package/harness/lib/routing/types.ts +52 -0
- package/harness/lib/sanity/checker.ts +45 -34
- package/harness/lib/sync/file-watcher.ts +26 -25
- package/harness/lib/sync/plan-sync.ts +22 -25
- package/harness/lib/sync/sync.test.ts +30 -4
- package/harness/skills/oh-fusion/DEEP.md +109 -86
- package/harness/skills/oh-fusion/SKILL.md +47 -33
- package/harness/skills/oh-manifest/SKILL.md +1 -0
- package/harness/skills/oh-review/DEEP.md +5 -3
- package/harness/skills/oh-review/SKILL.md +1 -0
- package/package.json +53 -55
|
@@ -8,14 +8,62 @@ export enum HookPhase {
|
|
|
8
8
|
LATE = 2,
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export interface
|
|
11
|
+
export interface HookContextBase {
|
|
12
12
|
sessionId: string;
|
|
13
13
|
agent: string;
|
|
14
14
|
directory: string;
|
|
15
15
|
sessions: Map<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface HookContextExtras {
|
|
19
|
+
_planCheck?: "missing" | "found";
|
|
20
|
+
_planFilePath?: string;
|
|
21
|
+
_planCheckInstruction?: string;
|
|
22
|
+
|
|
23
|
+
_shellPlatform?: string;
|
|
24
|
+
_shellType?: string;
|
|
25
|
+
_shellPreamble?: string;
|
|
26
|
+
|
|
27
|
+
_delegationDepth?: number;
|
|
28
|
+
_depthExceeded?: boolean;
|
|
29
|
+
_depthError?: string;
|
|
30
|
+
|
|
31
|
+
_confidenceLevel?: "HIGH" | "MEDIUM" | "LOW" | string;
|
|
32
|
+
_confidenceExchanges?: number;
|
|
33
|
+
|
|
34
|
+
// Guard configuration (centralized — replaces _routeTrackingConfig and _maxDelegationDepth)
|
|
35
|
+
_guardConfig?: import("../guards/guard-config.ts").GuardConfig;
|
|
36
|
+
_guardProgression?: import("../guards/guard-config.ts").GuardProgression;
|
|
37
|
+
|
|
38
|
+
// Subagent failure tracking
|
|
39
|
+
_subagentFailures?: number;
|
|
40
|
+
_subagentFailureThreshold?: number;
|
|
41
|
+
_optiRoute?: {
|
|
42
|
+
reason: string;
|
|
43
|
+
chain: Array<{
|
|
44
|
+
skill: string;
|
|
45
|
+
timestamp: number;
|
|
46
|
+
producedArtifact: boolean;
|
|
47
|
+
}>;
|
|
48
|
+
skillCounts: Record<string, number>;
|
|
49
|
+
unproductiveCount: number;
|
|
50
|
+
maxSkillRepeats: number;
|
|
51
|
+
maxUnproductiveHops: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
_memorySyncCount?: number;
|
|
55
|
+
_recoveryAttempt?: number;
|
|
56
|
+
_routingSkillsDir?: string;
|
|
57
|
+
_nextRoute?: import("../routing/index.ts").RuntimeRouteDecision;
|
|
58
|
+
|
|
16
59
|
[key: string]: unknown;
|
|
17
60
|
}
|
|
18
61
|
|
|
62
|
+
export type HookContext = HookContextBase & HookContextExtras;
|
|
63
|
+
|
|
64
|
+
export type HookContextPatch = Partial<HookContextBase> &
|
|
65
|
+
Partial<HookContextExtras>;
|
|
66
|
+
|
|
19
67
|
export interface HookMetadata {
|
|
20
68
|
name: string;
|
|
21
69
|
priority: number; // 0-100, higher = earlier within phase
|
|
@@ -34,7 +82,7 @@ export interface PreToolUseHook {
|
|
|
34
82
|
metadata: HookMetadata;
|
|
35
83
|
execute(
|
|
36
84
|
context: HookContext,
|
|
37
|
-
): Promise<{ result: HookResult; modifiedContext?:
|
|
85
|
+
): Promise<{ result: HookResult; modifiedContext?: HookContextPatch }>;
|
|
38
86
|
}
|
|
39
87
|
|
|
40
88
|
export interface PostToolUseHook {
|
|
@@ -18,7 +18,7 @@ import type {
|
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
|
|
20
20
|
export class MemoryManager {
|
|
21
|
-
private static instance: MemoryManager;
|
|
21
|
+
private static instance: MemoryManager | null = null;
|
|
22
22
|
|
|
23
23
|
private entries: Map<MemoryLevel, MemoryEntry[]> = new Map();
|
|
24
24
|
private config: MemoryConfig;
|
|
@@ -46,7 +46,7 @@ export class MemoryManager {
|
|
|
46
46
|
|
|
47
47
|
/** Reset singleton — used in tests for isolation. */
|
|
48
48
|
static resetInstance(): void {
|
|
49
|
-
MemoryManager.instance = null
|
|
49
|
+
MemoryManager.instance = null;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
// -----------------------------------------------------------------------
|
|
@@ -410,12 +410,6 @@ describe("PlanStore", () => {
|
|
|
410
410
|
assert.ok(content.includes("Do something"));
|
|
411
411
|
});
|
|
412
412
|
|
|
413
|
-
// ---- 21: getMerged returns empty for now --------------------------------
|
|
414
|
-
|
|
415
|
-
it("getMerged returns empty for now", async () => {
|
|
416
|
-
const result = await PlanStore.getMerged("session-1");
|
|
417
|
-
assert.deepEqual(result, []);
|
|
418
|
-
});
|
|
419
413
|
});
|
|
420
414
|
|
|
421
415
|
// ---------------------------------------------------------------------------
|
|
@@ -231,27 +231,7 @@ export class PlanStore {
|
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
|
|
235
|
-
* Merge parent context entries for child sessions.
|
|
236
|
-
* Returns all entries from both parent and current session context.
|
|
237
|
-
*/
|
|
238
|
-
static async getMerged(
|
|
239
|
-
sessionId: string,
|
|
240
|
-
parentSessionId?: string,
|
|
241
|
-
): Promise<MemoryEntry[]> {
|
|
242
|
-
const all: MemoryEntry[] = [];
|
|
243
|
-
|
|
244
|
-
// For now, this is a placeholder that returns empty — real merging
|
|
245
|
-
// requires the caller to provide plan paths. This stub hooks into
|
|
246
|
-
// the intended architecture without dictating I/O strategy.
|
|
247
|
-
if (parentSessionId) {
|
|
248
|
-
// In a real implementation, we would look up the parent session's
|
|
249
|
-
// plan file and merge its memory entries with the child's.
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return all;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
234
|
+
}
|
|
255
235
|
|
|
256
236
|
// ---------------------------------------------------------------------------
|
|
257
237
|
// Internal parsing helpers
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
let _planStorageOverride: string | undefined
|
|
6
|
+
|
|
7
|
+
export interface PlanAccess {
|
|
8
|
+
path: string
|
|
9
|
+
status: string | null
|
|
10
|
+
objective: string | null
|
|
11
|
+
summary: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function setPlanStorageDirForTest(dir: string | undefined): void {
|
|
15
|
+
_planStorageOverride = dir
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function planStorageDir(): string {
|
|
19
|
+
return _planStorageOverride ?? path.join(os.homedir(), ".local", "share", "openhermes", "plans")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getProjectName(projectDir: string): string {
|
|
23
|
+
return path.basename(projectDir)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ensureDir(dir: string): void {
|
|
27
|
+
try {
|
|
28
|
+
if (!fs.existsSync(dir)) {
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
33
|
+
console.error(`[openhermes] Failed to create directory ${dir}: ${msg}`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readPlanAccess(filePath: string): PlanAccess | null {
|
|
38
|
+
if (!fs.existsSync(filePath)) return null
|
|
39
|
+
const source = fs.readFileSync(filePath, "utf8")
|
|
40
|
+
const status = source.match(/^Status:\s*(.+)$/m)?.[1]?.trim() ?? null
|
|
41
|
+
const objective = source.match(/^Objective:\s*(.+)$/m)?.[1]?.trim() ?? null
|
|
42
|
+
if (!status && !objective) return null
|
|
43
|
+
const parts = [status ? `status=${status}` : null, objective ? `objective=${objective}` : null].filter(Boolean)
|
|
44
|
+
return {
|
|
45
|
+
path: filePath,
|
|
46
|
+
status,
|
|
47
|
+
objective,
|
|
48
|
+
summary: `Active plan: ${parts.join(" | ")}`,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolvePlanAccess(projectDir: string): PlanAccess | null {
|
|
53
|
+
const latest = findLatestPlanFile(projectDir)
|
|
54
|
+
if (!latest) return null
|
|
55
|
+
return readPlanAccess(latest)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function findLatestPlanFile(projectDir: string): string | null {
|
|
59
|
+
const projectName = getProjectName(projectDir)
|
|
60
|
+
const storage = planStorageDir()
|
|
61
|
+
const projectDirPath = path.join(storage, projectName)
|
|
62
|
+
if (!fs.existsSync(projectDirPath)) return null
|
|
63
|
+
let latest: string | null = null
|
|
64
|
+
let highest = -1
|
|
65
|
+
try {
|
|
66
|
+
for (const entry of fs.readdirSync(projectDirPath)) {
|
|
67
|
+
const m = entry.match(/^plan-(\d{3})\.md$/)
|
|
68
|
+
if (m) {
|
|
69
|
+
const n = parseInt(m[1], 10)
|
|
70
|
+
if (n > highest) {
|
|
71
|
+
highest = n
|
|
72
|
+
latest = path.join(projectDirPath, entry)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
return latest
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function ensurePlanFile(projectDir: string): string {
|
|
83
|
+
const access = resolvePlanAccess(projectDir)
|
|
84
|
+
if (access?.status === "active" || access?.status === "in-progress") {
|
|
85
|
+
return access.path
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const projectName = getProjectName(projectDir)
|
|
89
|
+
const storage = planStorageDir()
|
|
90
|
+
const projectDirPath = path.join(storage, projectName)
|
|
91
|
+
ensureDir(projectDirPath)
|
|
92
|
+
|
|
93
|
+
const latest = access?.path ?? findLatestPlanFile(projectDir)
|
|
94
|
+
let nextSeq = 1
|
|
95
|
+
if (latest) {
|
|
96
|
+
const m = path.basename(latest).match(/^plan-(\d{3})\.md$/)
|
|
97
|
+
if (m) nextSeq = parseInt(m[1], 10) + 1
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const seq = String(nextSeq).padStart(3, "0")
|
|
101
|
+
const planId = `${projectName}/plan-${seq}.md`
|
|
102
|
+
const planPath = path.join(projectDirPath, `plan-${seq}.md`)
|
|
103
|
+
const now = new Date().toISOString().replace("T", " ").slice(0, 16)
|
|
104
|
+
|
|
105
|
+
const content = [
|
|
106
|
+
`# PLAN: ${projectName}`,
|
|
107
|
+
"",
|
|
108
|
+
`Plan ID: ${planId}`,
|
|
109
|
+
`Project: ${projectName}`,
|
|
110
|
+
`Status: active`,
|
|
111
|
+
`Created: ${now}`,
|
|
112
|
+
`Updated: ${now}`,
|
|
113
|
+
`Project Path: ${projectDir}`,
|
|
114
|
+
`Plan Path: ${planPath}`,
|
|
115
|
+
`Objective: (pending classification)`,
|
|
116
|
+
"",
|
|
117
|
+
"## Tasks",
|
|
118
|
+
"",
|
|
119
|
+
"- [ ] (discoverable — pending classification)",
|
|
120
|
+
"",
|
|
121
|
+
].join("\n")
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
fs.writeFileSync(planPath, content, "utf8")
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
127
|
+
console.error(`[openhermes] Failed to write plan file ${planPath}: ${msg}`)
|
|
128
|
+
}
|
|
129
|
+
return planPath
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function readPlanSummary(projectDir: string): string | null {
|
|
133
|
+
return resolvePlanAccess(projectDir)?.summary ?? null
|
|
134
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { consumeRouteGuidance, parseSkillFrontmatter, resolveRoute } from "./index.ts";
|
|
4
|
+
|
|
5
|
+
describe("routing frontmatter", () => {
|
|
6
|
+
it("parses scalar route values", () => {
|
|
7
|
+
const parsed = parseSkillFrontmatter(`---
|
|
8
|
+
name: oh-test
|
|
9
|
+
route:
|
|
10
|
+
pass: oh-builder
|
|
11
|
+
fail: oh-review
|
|
12
|
+
blocker: surface
|
|
13
|
+
---`);
|
|
14
|
+
|
|
15
|
+
assert.ok(parsed);
|
|
16
|
+
assert.deepEqual(parsed.route, {
|
|
17
|
+
pass: ["oh-builder"],
|
|
18
|
+
fail: ["oh-review"],
|
|
19
|
+
blocker: ["surface"],
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("parses yaml route lists", () => {
|
|
24
|
+
const parsed = parseSkillFrontmatter(`---
|
|
25
|
+
name: oh-test
|
|
26
|
+
route:
|
|
27
|
+
pass:
|
|
28
|
+
- oh-gauntlet
|
|
29
|
+
- oh-ship
|
|
30
|
+
fail:
|
|
31
|
+
- oh-builder
|
|
32
|
+
blocker: surface
|
|
33
|
+
---`);
|
|
34
|
+
|
|
35
|
+
assert.ok(parsed);
|
|
36
|
+
assert.deepEqual(parsed.route, {
|
|
37
|
+
pass: ["oh-gauntlet", "oh-ship"],
|
|
38
|
+
fail: ["oh-builder"],
|
|
39
|
+
blocker: ["surface"],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("parses inline route arrays", () => {
|
|
44
|
+
const parsed = parseSkillFrontmatter(`---
|
|
45
|
+
name: oh-test
|
|
46
|
+
route:
|
|
47
|
+
pass: [oh-gauntlet, oh-ship]
|
|
48
|
+
fail: [surface, oh-expert]
|
|
49
|
+
blocker: surface
|
|
50
|
+
---`);
|
|
51
|
+
|
|
52
|
+
assert.ok(parsed);
|
|
53
|
+
assert.deepEqual(parsed.route, {
|
|
54
|
+
pass: ["oh-gauntlet", "oh-ship"],
|
|
55
|
+
fail: ["surface", "oh-expert"],
|
|
56
|
+
blocker: ["surface"],
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("resolveRoute", () => {
|
|
62
|
+
const route = {
|
|
63
|
+
pass: ["oh-gauntlet", "oh-ship"],
|
|
64
|
+
fail: ["oh-builder"],
|
|
65
|
+
blocker: ["surface"],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
it("defaults to the first candidate for an outcome", () => {
|
|
69
|
+
const resolved = resolveRoute(route, { outcome: "pass" });
|
|
70
|
+
assert.deepEqual(resolved, {
|
|
71
|
+
outcome: "pass",
|
|
72
|
+
candidates: ["oh-gauntlet", "oh-ship"],
|
|
73
|
+
selected: "oh-gauntlet",
|
|
74
|
+
reason: 'Selected first declared route for outcome "pass".',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("prefers an evidence target when it matches a candidate", () => {
|
|
79
|
+
const resolved = resolveRoute(route, { outcome: "pass", target: "oh-ship" });
|
|
80
|
+
assert.deepEqual(resolved, {
|
|
81
|
+
outcome: "pass",
|
|
82
|
+
candidates: ["oh-gauntlet", "oh-ship"],
|
|
83
|
+
selected: "oh-ship",
|
|
84
|
+
reason: 'Selected "oh-ship" from output evidence.',
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("prefers oh-ship for verified done ship work", () => {
|
|
89
|
+
const resolved = resolveRoute(route, {
|
|
90
|
+
outcome: "pass",
|
|
91
|
+
verification: "verified",
|
|
92
|
+
action: "done",
|
|
93
|
+
work: "ship",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
assert.deepEqual(resolved, {
|
|
97
|
+
outcome: "pass",
|
|
98
|
+
verification: "verified",
|
|
99
|
+
action: "done",
|
|
100
|
+
work: "ship",
|
|
101
|
+
candidates: ["oh-gauntlet", "oh-ship"],
|
|
102
|
+
selected: "oh-ship",
|
|
103
|
+
reason: 'Selected "oh-ship" for verified ship-ready work.',
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("prefers oh-gauntlet for unverified work", () => {
|
|
108
|
+
const resolved = resolveRoute(route, {
|
|
109
|
+
outcome: "pass",
|
|
110
|
+
verification: "unverified",
|
|
111
|
+
action: "done",
|
|
112
|
+
work: "ship",
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
assert.deepEqual(resolved, {
|
|
116
|
+
outcome: "pass",
|
|
117
|
+
verification: "unverified",
|
|
118
|
+
action: "done",
|
|
119
|
+
work: "ship",
|
|
120
|
+
candidates: ["oh-gauntlet", "oh-ship"],
|
|
121
|
+
selected: "oh-gauntlet",
|
|
122
|
+
reason: 'Selected "oh-gauntlet" because work is still unverified.',
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("prefers oh-gauntlet for verify work", () => {
|
|
127
|
+
const resolved = resolveRoute(route, {
|
|
128
|
+
outcome: "pass",
|
|
129
|
+
verification: "verified",
|
|
130
|
+
action: "done",
|
|
131
|
+
work: "verify",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
assert.deepEqual(resolved, {
|
|
135
|
+
outcome: "pass",
|
|
136
|
+
verification: "verified",
|
|
137
|
+
action: "done",
|
|
138
|
+
work: "verify",
|
|
139
|
+
candidates: ["oh-gauntlet", "oh-ship"],
|
|
140
|
+
selected: "oh-gauntlet",
|
|
141
|
+
reason: 'Selected "oh-gauntlet" for verification work.',
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("prefers oh-builder for fixable implementation work", () => {
|
|
146
|
+
const resolved = resolveRoute({
|
|
147
|
+
pass: ["oh-gauntlet", "oh-ship", "oh-builder"],
|
|
148
|
+
fail: ["oh-builder"],
|
|
149
|
+
blocker: ["surface"],
|
|
150
|
+
}, {
|
|
151
|
+
outcome: "pass",
|
|
152
|
+
verification: "verified",
|
|
153
|
+
action: "fixable",
|
|
154
|
+
work: "implement",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
assert.deepEqual(resolved, {
|
|
158
|
+
outcome: "pass",
|
|
159
|
+
verification: "verified",
|
|
160
|
+
action: "fixable",
|
|
161
|
+
work: "implement",
|
|
162
|
+
candidates: ["oh-gauntlet", "oh-ship", "oh-builder"],
|
|
163
|
+
selected: "oh-builder",
|
|
164
|
+
reason: 'Selected "oh-builder" for fixable implementation work.',
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("consumeRouteGuidance", () => {
|
|
170
|
+
it("promotes a selected route into an explicit next-route instruction", () => {
|
|
171
|
+
const consumed = consumeRouteGuidance([
|
|
172
|
+
"Review complete",
|
|
173
|
+
'ROUTE_GUIDANCE: {"outcome":"pass","candidates":["oh-gauntlet","oh-ship"],"selected":"oh-ship","reason":"Selected \\\"oh-ship\\\" from output evidence."}',
|
|
174
|
+
].join("\n"));
|
|
175
|
+
|
|
176
|
+
assert.equal(consumed.selected, "oh-ship");
|
|
177
|
+
assert.match(consumed.output, /NEXT_ROUTE: oh-ship/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("leaves output unchanged when no route guidance is present", () => {
|
|
181
|
+
const output = "plain output";
|
|
182
|
+
const consumed = consumeRouteGuidance(output);
|
|
183
|
+
|
|
184
|
+
assert.equal(consumed.selected, null);
|
|
185
|
+
assert.equal(consumed.output, output);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("ignores malformed route guidance safely", () => {
|
|
189
|
+
const output = 'Review complete\nROUTE_GUIDANCE: {"selected":42}';
|
|
190
|
+
const consumed = consumeRouteGuidance(output);
|
|
191
|
+
|
|
192
|
+
assert.equal(consumed.selected, null);
|
|
193
|
+
assert.equal(consumed.output, output);
|
|
194
|
+
});
|
|
195
|
+
});
|