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,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
+ });
@@ -0,0 +1,125 @@
1
+ import fs from "node:fs";
2
+ import type { RouteOutcome, SkillRouteMap, SkillRoutingFrontmatter } from "./types.ts";
3
+
4
+ const EMPTY_ROUTES: SkillRouteMap = {
5
+ pass: [],
6
+ fail: [],
7
+ blocker: [],
8
+ };
9
+
10
+ function stripQuotes(value: string): string {
11
+ return value.trim().replace(/^['"]|['"]$/g, "");
12
+ }
13
+
14
+ function parseRouteValue(value: string): string[] {
15
+ const trimmed = value.trim();
16
+ if (!trimmed) return [];
17
+
18
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
19
+ return trimmed
20
+ .slice(1, -1)
21
+ .split(",")
22
+ .map((entry) => stripQuotes(entry))
23
+ .filter(Boolean);
24
+ }
25
+
26
+ return [stripQuotes(trimmed)].filter(Boolean);
27
+ }
28
+
29
+ function isRouteOutcome(value: string): value is RouteOutcome {
30
+ return value === "pass" || value === "fail" || value === "blocker";
31
+ }
32
+
33
+ export function extractFrontmatter(source: string): string | null {
34
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
35
+ return match?.[1] ?? null;
36
+ }
37
+
38
+ export function parseSkillFrontmatter(source: string): SkillRoutingFrontmatter | null {
39
+ const rawFrontmatter = extractFrontmatter(source);
40
+ if (!rawFrontmatter) return null;
41
+
42
+ const route: SkillRouteMap = {
43
+ pass: [],
44
+ fail: [],
45
+ blocker: [],
46
+ };
47
+
48
+ let name: string | undefined;
49
+ let description: string | undefined;
50
+ let tier: string | undefined;
51
+ let inRouteBlock = false;
52
+ let activeRouteKey: RouteOutcome | null = null;
53
+
54
+ for (const rawLine of rawFrontmatter.split(/\r?\n/)) {
55
+ const line = rawLine.trimEnd();
56
+ const trimmed = line.trim();
57
+
58
+ if (!trimmed) continue;
59
+
60
+ if (/^route:\s*$/.test(trimmed)) {
61
+ inRouteBlock = true;
62
+ activeRouteKey = null;
63
+ continue;
64
+ }
65
+
66
+ if (inRouteBlock) {
67
+ const routeMatch = line.match(/^\s{2,}(pass|fail|blocker):\s*(.*)$/);
68
+ if (routeMatch && isRouteOutcome(routeMatch[1])) {
69
+ activeRouteKey = routeMatch[1];
70
+ route[activeRouteKey].push(...parseRouteValue(routeMatch[2]));
71
+ continue;
72
+ }
73
+
74
+ const listMatch = line.match(/^\s{4,}-\s+(.+)$/);
75
+ if (listMatch && activeRouteKey) {
76
+ route[activeRouteKey].push(stripQuotes(listMatch[1]));
77
+ continue;
78
+ }
79
+
80
+ if (!/^\s/.test(line)) {
81
+ inRouteBlock = false;
82
+ activeRouteKey = null;
83
+ } else {
84
+ activeRouteKey = null;
85
+ continue;
86
+ }
87
+ }
88
+
89
+ const fieldMatch = line.match(/^(name|description|tier):\s*(.+)$/);
90
+ if (!fieldMatch) continue;
91
+
92
+ const value = stripQuotes(fieldMatch[2]);
93
+ switch (fieldMatch[1]) {
94
+ case "name":
95
+ name = value;
96
+ break;
97
+ case "description":
98
+ description = value;
99
+ break;
100
+ case "tier":
101
+ tier = value;
102
+ break;
103
+ }
104
+ }
105
+
106
+ return {
107
+ name,
108
+ description,
109
+ tier,
110
+ route,
111
+ };
112
+ }
113
+
114
+ export function readSkillFrontmatter(skillFilePath: string): SkillRoutingFrontmatter | null {
115
+ if (!fs.existsSync(skillFilePath)) return null;
116
+ return parseSkillFrontmatter(fs.readFileSync(skillFilePath, "utf8"));
117
+ }
118
+
119
+ export function emptySkillRoutes(): SkillRouteMap {
120
+ return {
121
+ pass: [...EMPTY_ROUTES.pass],
122
+ fail: [...EMPTY_ROUTES.fail],
123
+ blocker: [...EMPTY_ROUTES.blocker],
124
+ };
125
+ }
@@ -0,0 +1,52 @@
1
+ export const ROUTE_OUTCOMES = ["pass", "fail", "blocker"] as const;
2
+ export const ROUTE_VERIFICATIONS = ["verified", "unverified"] as const;
3
+ export const ROUTE_ACTIONS = ["done", "fixable", "needs-context", "blocked"] as const;
4
+ export const ROUTE_WORK_TYPES = ["implement", "verify", "ship", "diagnose", "surface"] as const;
5
+
6
+ export type RouteOutcome = (typeof ROUTE_OUTCOMES)[number];
7
+ export type RouteVerification = (typeof ROUTE_VERIFICATIONS)[number];
8
+ export type RouteAction = (typeof ROUTE_ACTIONS)[number];
9
+ export type RouteWork = (typeof ROUTE_WORK_TYPES)[number];
10
+
11
+ export interface SkillRouteMap {
12
+ pass: string[];
13
+ fail: string[];
14
+ blocker: string[];
15
+ }
16
+
17
+ export interface SkillRoutingFrontmatter {
18
+ name?: string;
19
+ description?: string;
20
+ tier?: string;
21
+ route: SkillRouteMap;
22
+ }
23
+
24
+ export interface RouteEvidence {
25
+ outcome: RouteOutcome;
26
+ verification?: RouteVerification;
27
+ action?: RouteAction;
28
+ work?: RouteWork;
29
+ target?: string;
30
+ reason?: string;
31
+ }
32
+
33
+ export interface RouteResolution {
34
+ outcome: RouteOutcome;
35
+ verification?: RouteVerification;
36
+ action?: RouteAction;
37
+ work?: RouteWork;
38
+ candidates: string[];
39
+ selected: string | null;
40
+ reason: string;
41
+ }
42
+
43
+ export interface RuntimeRouteDecision {
44
+ selected: string;
45
+ source: "next_route" | "route_guidance";
46
+ outcome?: RouteOutcome;
47
+ verification?: RouteVerification;
48
+ action?: RouteAction;
49
+ work?: RouteWork;
50
+ candidates?: string[];
51
+ reason?: string;
52
+ }
@@ -0,0 +1,127 @@
1
+ // ---------------------------------------------------------------------------
2
+ // AnomalyTracker — singleton that tracks consecutive anomaly records per session
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import type { AnomalyRecord, AnomalyTrackerConfig, SanityResult } from "./interfaces.ts";
6
+
7
+ const DEFAULT_CONFIG: AnomalyTrackerConfig = {
8
+ maxConsecutiveAnomalies: 2,
9
+ escalationMessage: "recovery: compact context",
10
+ };
11
+
12
+ export class AnomalyTracker {
13
+ private static instance: AnomalyTracker;
14
+
15
+ private records = new Map<string, AnomalyRecord>();
16
+ private config: AnomalyTrackerConfig;
17
+
18
+ private constructor(config?: Partial<AnomalyTrackerConfig>) {
19
+ this.config = { ...DEFAULT_CONFIG, ...config };
20
+ }
21
+
22
+ /** Get the singleton instance. */
23
+ static getInstance(config?: Partial<AnomalyTrackerConfig>): AnomalyTracker {
24
+ if (!AnomalyTracker.instance) {
25
+ AnomalyTracker.instance = new AnomalyTracker(config);
26
+ }
27
+ return AnomalyTracker.instance;
28
+ }
29
+
30
+ /**
31
+ * Record a sanity result for a session.
32
+ * If unhealthy: increments consecutive count, updates reason/timestamp.
33
+ * If healthy: resets consecutive count to 0.
34
+ * Returns tracking info including whether escalation is needed.
35
+ */
36
+ record(
37
+ sessionId: string,
38
+ result: SanityResult,
39
+ ): {
40
+ shouldEscalate: boolean;
41
+ consecutiveAnomalies: number;
42
+ recoveryMessage?: string;
43
+ } {
44
+ const existing = this.records.get(sessionId);
45
+
46
+ if (!result.isHealthy) {
47
+ const count = (existing?.count ?? 0) + 1;
48
+ this.records.set(sessionId, {
49
+ sessionId,
50
+ count,
51
+ lastReason: result.reason ?? "Unknown anomaly",
52
+ lastTimestamp: Date.now(),
53
+ });
54
+
55
+ const shouldEscalate = count >= this.config.maxConsecutiveAnomalies;
56
+
57
+ return {
58
+ shouldEscalate,
59
+ consecutiveAnomalies: count,
60
+ recoveryMessage: shouldEscalate ? this.config.escalationMessage : undefined,
61
+ };
62
+ }
63
+
64
+ // Healthy output — reset counter
65
+ if (existing) {
66
+ this.records.set(sessionId, {
67
+ ...existing,
68
+ count: 0,
69
+ lastReason: "reset on healthy output",
70
+ lastTimestamp: Date.now(),
71
+ });
72
+ }
73
+
74
+ return {
75
+ shouldEscalate: false,
76
+ consecutiveAnomalies: 0,
77
+ };
78
+ }
79
+
80
+ /** Get the current anomaly record for a session. */
81
+ getRecord(sessionId: string): AnomalyRecord | undefined {
82
+ return this.records.get(sessionId);
83
+ }
84
+
85
+ /** Clear anomaly record for a specific session. */
86
+ clearSession(sessionId: string): void {
87
+ this.records.delete(sessionId);
88
+ }
89
+
90
+ /** Reset all tracking state (useful in tests). */
91
+ resetAll(): void {
92
+ this.records.clear();
93
+ }
94
+
95
+ /** Get the current config. */
96
+ getConfig(): AnomalyTrackerConfig {
97
+ return { ...this.config };
98
+ }
99
+
100
+ /** Update config at runtime. */
101
+ setConfig(config: Partial<AnomalyTrackerConfig>): void {
102
+ this.config = { ...this.config, ...config };
103
+ }
104
+
105
+ // ── Cross-invocation identical output detection ────────────────
106
+
107
+ private lastOutput: string | null = null;
108
+ private identicalOutputCount = 0;
109
+ readonly MAX_IDENTICAL_OUTPUTS = 3;
110
+
111
+ /**
112
+ * Track output for repeated identical content.
113
+ * Returns true if output should be flagged as degenerate.
114
+ */
115
+ trackOutput(text: string): boolean {
116
+ if (text === this.lastOutput) {
117
+ this.identicalOutputCount++;
118
+ if (this.identicalOutputCount >= this.MAX_IDENTICAL_OUTPUTS) {
119
+ return true; // Flagged — repeated identical output
120
+ }
121
+ } else {
122
+ this.identicalOutputCount = 0;
123
+ }
124
+ this.lastOutput = text;
125
+ return false;
126
+ }
127
+ }
@@ -0,0 +1,189 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Output Sanity Checker — detect LLM output degeneration patterns
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import type { SanityResult } from "./interfaces.ts";
6
+ import { AnomalyTracker } from "./anomaly-tracker.ts";
7
+
8
+ /**
9
+ * Check a text string for output degeneration patterns.
10
+ * Returns unhealthy with severity + reason on first matching pattern.
11
+ * Returns healthy if no pattern matches.
12
+ *
13
+ * Check ordering: all critical-severity checks first (most specific first),
14
+ * then warning-severity checks. This ensures the most actionable, severe
15
+ * issues are reported before mild ones.
16
+ *
17
+ * Accepts an optional AnomalyTracker for cross-invocation dedup detection.
18
+ */
19
+ export function checkOutputSanity(
20
+ text: unknown,
21
+ anomalyTracker?: AnomalyTracker,
22
+ ): SanityResult {
23
+ if (typeof text !== "string") {
24
+ return {
25
+ isHealthy: false,
26
+ severity: "critical",
27
+ reason: "Output is not a string (possibly undefined/null)",
28
+ patternName: "empty_output",
29
+ };
30
+ }
31
+ if (text.length === 0) {
32
+ return {
33
+ isHealthy: false,
34
+ severity: "warning",
35
+ reason: "Output is an empty string",
36
+ patternName: "empty_output",
37
+ };
38
+ }
39
+
40
+ // ═══════════════════════════════════════════════════════════════════
41
+ // CRITICAL checks — severe degeneration
42
+ // ═══════════════════════════════════════════════════════════════════
43
+
44
+ // ── 1. Single character repetition ──────────────────────────────
45
+ // 16+ consecutive identical characters
46
+ const singleCharMatch = text.match(/(.)\1{15,}/);
47
+ if (singleCharMatch) {
48
+ return {
49
+ isHealthy: false,
50
+ severity: "critical",
51
+ reason: `Single character repetition detected: "${singleCharMatch[0].slice(0, 20)}..."`,
52
+ patternName: "single_char_repetition",
53
+ };
54
+ }
55
+
56
+ // ── 2. Short pattern loop ───────────────────────────────────────
57
+ // 9+ repetitions of a 2-6 character sequence
58
+ const patternLoopMatch = text.match(/(.{2,6})\1{8,}/);
59
+ if (patternLoopMatch) {
60
+ return {
61
+ isHealthy: false,
62
+ severity: "critical",
63
+ reason: `Pattern loop detected: "${patternLoopMatch[0].slice(0, 30)}..."`,
64
+ patternName: "pattern_loop",
65
+ };
66
+ }
67
+
68
+ // ── 3. Excessive box/block drawing characters ───────────────────
69
+ // Unicode box drawing, block elements, and Braille patterns
70
+ const boxDrawChars = text.match(/[\u2500-\u257f\u2580-\u259f\u2800-\u28ff]/g);
71
+ if (boxDrawChars && boxDrawChars.length > 100) {
72
+ const ratio = boxDrawChars.length / text.length;
73
+ if (ratio > 0.3) {
74
+ return {
75
+ isHealthy: false,
76
+ severity: "critical",
77
+ reason: `Visual gibberish detected: ${boxDrawChars.length} box/block chars (${(ratio * 100).toFixed(1)}% of output)`,
78
+ patternName: "visual_gibberish",
79
+ };
80
+ }
81
+ }
82
+
83
+ // ── 4. CJK character spam ─────────────────────────────────────
84
+ // Lots of CJK characters with very few unique ones
85
+ const cjkChars = text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g);
86
+ if (cjkChars && cjkChars.length > 200) {
87
+ const uniqueCjk = new Set(cjkChars).size;
88
+ if (uniqueCjk < 10 && cjkChars.length / uniqueCjk > 20) {
89
+ return {
90
+ isHealthy: false,
91
+ severity: "critical",
92
+ reason: `CJK character spam detected: ${cjkChars.length} chars, ${uniqueCjk} unique (ratio ${(cjkChars.length / uniqueCjk).toFixed(0)})`,
93
+ patternName: "cjk_spam",
94
+ };
95
+ }
96
+ }
97
+
98
+ // ── 5. Low character diversity ────────────────────────────────
99
+ // General catch-all for text with very few distinct characters
100
+ if (text.length > 200) {
101
+ const cleanText = text.replace(/\s/g, "");
102
+ if (cleanText.length > 0) {
103
+ const uniqueChars = new Set(cleanText).size;
104
+ const diversity = uniqueChars / cleanText.length;
105
+ if (diversity < 0.02) {
106
+ return {
107
+ isHealthy: false,
108
+ severity: "critical",
109
+ reason: `Low information density: ${uniqueChars} unique chars out of ${cleanText.length} (ratio ${diversity.toFixed(4)} < 0.02)`,
110
+ patternName: "low_diversity",
111
+ };
112
+ }
113
+ }
114
+ }
115
+
116
+ // ═══════════════════════════════════════════════════════════════════
117
+ // WARNING checks — mild or context-dependent issues
118
+ // ═══════════════════════════════════════════════════════════════════
119
+
120
+ // ── 6. Excessive JSON/error stack lines ─────────────────────────
121
+ const lines = text.split(/\r?\n/);
122
+ let errorStackLineCount = 0;
123
+ const repetitionLines: string[] = [];
124
+
125
+ for (const line of lines) {
126
+ const trimmed = line.trim();
127
+ if (line.includes("Error:") || trimmed.startsWith("at ") || line.includes("Exception:")) {
128
+ errorStackLineCount++;
129
+ }
130
+
131
+ if (trimmed.length > 10) {
132
+ repetitionLines.push(line);
133
+ }
134
+ }
135
+
136
+ if (errorStackLineCount > 5) {
137
+ return {
138
+ isHealthy: false,
139
+ severity: "warning",
140
+ reason: `Error stack bleed detected: ${errorStackLineCount} error/stack lines`,
141
+ patternName: "error_stack_bleed",
142
+ };
143
+ }
144
+
145
+ // ── 7. Line-by-line repetition ──────────────────────────────────
146
+ if (repetitionLines.length > 10) {
147
+ const uniqueLines = new Set(repetitionLines);
148
+ if (uniqueLines.size < repetitionLines.length * 0.2) {
149
+ return {
150
+ isHealthy: false,
151
+ severity: "warning",
152
+ reason: `Excessive line repetition: ${uniqueLines.size} unique lines out of ${repetitionLines.length} (${(uniqueLines.size / repetitionLines.length * 100).toFixed(0)}% unique)`,
153
+ patternName: "line_repetition",
154
+ };
155
+ }
156
+ }
157
+
158
+ // ── 8. Empty/tiny output ────────────────────────────────────────
159
+ // Only flag if the entire output is small enough to be suspicious
160
+ // (exclude common status messages like "ok", "done")
161
+ if (text.length < 50 && text.length > 0) {
162
+ const minimalWords = ["ok", "done", "yes", "no", "passed", "failed", "error", "null", "undefined", "true", "false"];
163
+ const trimmed = text.trim().toLowerCase();
164
+ if (!minimalWords.includes(trimmed) && !/^[\d.]+$/.test(trimmed)) {
165
+ return {
166
+ isHealthy: false,
167
+ severity: "warning",
168
+ reason: `Output too short: ${text.length} characters`,
169
+ patternName: "output_too_short",
170
+ };
171
+ }
172
+ }
173
+
174
+ // ── 9. Cross-invocation dedup check ────────────────────────────────
175
+ if (anomalyTracker) {
176
+ const isRepeated = anomalyTracker.trackOutput(text);
177
+ if (isRepeated) {
178
+ return {
179
+ isHealthy: false,
180
+ severity: "warning",
181
+ reason: `Output identical to previous ${anomalyTracker.MAX_IDENTICAL_OUTPUTS} invocations`,
182
+ patternName: "repeated_identical_output",
183
+ };
184
+ }
185
+ }
186
+
187
+ // No pattern matched — healthy
188
+ return { isHealthy: true, severity: "ok" };
189
+ }
@@ -0,0 +1,13 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Sanity Checker module — barrel export
3
+ // ---------------------------------------------------------------------------
4
+
5
+ export type {
6
+ Severity,
7
+ SanityResult,
8
+ AnomalyRecord,
9
+ AnomalyTrackerConfig,
10
+ } from "./interfaces.ts";
11
+
12
+ export { checkOutputSanity } from "./checker.ts";
13
+ export { AnomalyTracker } from "./anomaly-tracker.ts";