openhermes 4.12.1 → 4.13.0
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 +11 -17
- package/bootstrap.ts +118 -126
- package/docs/HOW-IT-WORKS.md +162 -0
- package/docs/adr/ADR-0001-rebuild-vs-increment.md +30 -0
- package/docs/adr/ADR-0002-routing-graph-vs-linear-chain.md +36 -0
- package/docs/adr/ADR-0003-per-directory-plan-storage.md +34 -0
- package/docs/adr/ADR-0004-composer-fragment-architecture.md +42 -0
- package/docs/adr/ADR-0005-hook-system-design.md +42 -0
- package/docs/adr/README.md +9 -0
- package/harness/codex/AUTOPILOT.md +35 -40
- package/harness/codex/CHARTER.md +3 -3
- package/harness/lib/composer/compose.test.ts +29 -29
- package/harness/lib/composer/fragments/02-delegation.md +5 -5
- package/harness/lib/composer/fragments/04-task-flow.md +13 -13
- package/harness/lib/composer/fragments/08-routing.md +1 -1
- package/harness/lib/composer/fragments/09-guardrails.md +25 -25
- package/harness/lib/composer/index.ts +1 -1
- package/harness/lib/guards/guard-config.ts +72 -72
- package/harness/lib/hooks/builtins/confidence-gate-hook.ts +9 -9
- package/harness/lib/hooks/builtins/delegation-depth-hook.ts +1 -1
- package/harness/lib/hooks/builtins/dynamic-route-hook.ts +99 -99
- package/harness/lib/hooks/builtins/next-route-hook.ts +24 -24
- package/harness/lib/hooks/builtins/plan-check-hook.ts +5 -5
- package/harness/lib/hooks/builtins/route-tracking-hook.ts +1 -1
- package/harness/lib/hooks/hooks.test.ts +160 -324
- package/harness/lib/hooks/index.ts +38 -42
- package/harness/lib/hooks/registry.ts +309 -416
- package/harness/lib/hooks/types.ts +116 -119
- package/harness/lib/plans/plan-location.ts +134 -134
- package/harness/lib/routing/index.ts +21 -21
- package/harness/lib/routing/route-guidance.ts +147 -147
- package/harness/lib/routing/route-resolver.ts +58 -58
- package/harness/lib/routing/routing.test.ts +195 -195
- package/harness/lib/routing/skill-frontmatter.ts +125 -125
- package/harness/lib/routing/types.ts +52 -52
- package/harness/skills/oh-ascii/SKILL.md +1 -1
- package/harness/skills/oh-fusion/DEEP.md +109 -109
- package/harness/skills/oh-fusion/SKILL.md +47 -47
- package/harness/skills/oh-init/DEEP.md +2 -2
- package/harness/skills/oh-plan-review/DEEP.md +1 -1
- package/harness/skills/oh-planner/DEEP.md +3 -3
- package/harness/skills/oh-review/DEEP.md +5 -5
- package/package.json +56 -53
- package/harness/lib/background/background.test.ts +0 -216
- package/harness/lib/background/index.ts +0 -7
- package/harness/lib/background/interfaces.ts +0 -31
- package/harness/lib/background/manager.ts +0 -320
- package/harness/lib/hooks/builtins/error-recovery-hook.ts +0 -107
- package/harness/lib/hooks/builtins/memory-sync-hook.ts +0 -73
- package/harness/lib/hooks/builtins/sanity-check-hook.ts +0 -52
- package/harness/lib/hooks/builtins/subagent-failure-hook.ts +0 -93
- package/harness/lib/memory/index.ts +0 -18
- package/harness/lib/memory/interfaces.ts +0 -53
- package/harness/lib/memory/memory-manager.ts +0 -205
- package/harness/lib/memory/memory.test.ts +0 -485
- package/harness/lib/memory/plan-store.ts +0 -346
- package/harness/lib/recovery/handler.ts +0 -243
- package/harness/lib/recovery/index.ts +0 -14
- package/harness/lib/recovery/interfaces.ts +0 -48
- package/harness/lib/recovery/patterns.ts +0 -149
- package/harness/lib/recovery/recovery.test.ts +0 -312
- package/harness/lib/sanity/anomaly-tracker.ts +0 -127
- package/harness/lib/sanity/checker.ts +0 -189
- package/harness/lib/sanity/index.ts +0 -13
- package/harness/lib/sanity/interfaces.ts +0 -24
- package/harness/lib/sanity/sanity.test.ts +0 -472
- package/harness/lib/sync/file-watcher.ts +0 -175
- package/harness/lib/sync/index.ts +0 -11
- package/harness/lib/sync/interfaces.ts +0 -27
- package/harness/lib/sync/plan-sync.ts +0 -533
- package/harness/lib/sync/sync.test.ts +0 -858
|
@@ -1,472 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// Sanity Checker — tests
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
|
|
5
|
-
import { describe, it, before, after } from "node:test";
|
|
6
|
-
import assert from "node:assert/strict";
|
|
7
|
-
import { checkOutputSanity } from "./checker.ts";
|
|
8
|
-
import { AnomalyTracker } from "./anomaly-tracker.ts";
|
|
9
|
-
import type { SanityResult } from "./interfaces.ts";
|
|
10
|
-
|
|
11
|
-
// ── Helper ────────────────────────────────────────────────────────────
|
|
12
|
-
|
|
13
|
-
function assertHealthy(result: SanityResult, msg?: string): void {
|
|
14
|
-
assert.ok(result.isHealthy, msg ?? `Expected healthy, got: ${result.reason}`);
|
|
15
|
-
assert.equal(result.severity, "ok");
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function assertUnhealthy(
|
|
19
|
-
result: SanityResult,
|
|
20
|
-
expectedSeverity: "warning" | "critical",
|
|
21
|
-
expectedPattern?: string,
|
|
22
|
-
msg?: string,
|
|
23
|
-
): void {
|
|
24
|
-
assert.equal(result.isHealthy, false, msg ?? "Expected unhealthy");
|
|
25
|
-
assert.equal(
|
|
26
|
-
result.severity,
|
|
27
|
-
expectedSeverity,
|
|
28
|
-
msg ?? `Expected severity ${expectedSeverity}, got ${result.severity}`,
|
|
29
|
-
);
|
|
30
|
-
if (expectedPattern) {
|
|
31
|
-
assert.equal(
|
|
32
|
-
result.patternName,
|
|
33
|
-
expectedPattern,
|
|
34
|
-
msg ?? `Expected pattern ${expectedPattern}, got ${result.patternName}`,
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// ── Tests ──────────────────────────────────────────────────────────────
|
|
40
|
-
|
|
41
|
-
describe("checkOutputSanity — detection patterns", () => {
|
|
42
|
-
// ── 1. Single character repetition ────────────────────────────────
|
|
43
|
-
|
|
44
|
-
it("detects single character repetition (16+ same chars)", () => {
|
|
45
|
-
const result = checkOutputSanity(
|
|
46
|
-
"Leading text " + "a".repeat(20) + " trailing text to exceed 50 chars total and avoid short output detection",
|
|
47
|
-
);
|
|
48
|
-
assertUnhealthy(result, "critical", "single_char_repetition");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("allows short character repetition (< 16)", () => {
|
|
52
|
-
// At least 50 chars to avoid short-output warning
|
|
53
|
-
const text =
|
|
54
|
-
"This line has " +
|
|
55
|
-
"a".repeat(15) +
|
|
56
|
-
" but not 16+ same chars, so it should definitely pass this check fine.";
|
|
57
|
-
assert.ok(
|
|
58
|
-
text.length >= 50,
|
|
59
|
-
`Test string must be >= 50 chars (was ${text.length})`,
|
|
60
|
-
);
|
|
61
|
-
const result = checkOutputSanity(text);
|
|
62
|
-
assertHealthy(result);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("detects repeated spaces", () => {
|
|
66
|
-
const text =
|
|
67
|
-
"hello" +
|
|
68
|
-
" ".repeat(20) +
|
|
69
|
-
"world and some more text here to make the string exceed 50 chars in total so we avoid the short check";
|
|
70
|
-
const result = checkOutputSanity(text);
|
|
71
|
-
assertUnhealthy(result, "critical", "single_char_repetition");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
// ── 2. Short pattern loop ─────────────────────────────────────────
|
|
75
|
-
|
|
76
|
-
it("detects short pattern loop (9+ repeats)", () => {
|
|
77
|
-
const result = checkOutputSanity(
|
|
78
|
-
"prefix " + "ab".repeat(12) + " suffix that brings total well past 50 characters to avoid short output detection",
|
|
79
|
-
);
|
|
80
|
-
assertUnhealthy(result, "critical", "pattern_loop");
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("allows short repetitions (< 9)", () => {
|
|
84
|
-
// 8 of each letter = 8*7 = 56 chars, no single-char run >= 16
|
|
85
|
-
const text = "A".repeat(8) + "B".repeat(8) + "C".repeat(8) + "D".repeat(8) + "E".repeat(8) + "F".repeat(8) + "G".repeat(8);
|
|
86
|
-
assert.ok(text.length >= 50, `Test string must be >= 50 chars (was ${text.length})`);
|
|
87
|
-
const result = checkOutputSanity(text);
|
|
88
|
-
assertHealthy(result);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("detects longer pattern loop", () => {
|
|
92
|
-
const result = checkOutputSanity(
|
|
93
|
-
"start " + "hello".repeat(10) + " end with more text to exceed 50 character limit for short detection sure",
|
|
94
|
-
);
|
|
95
|
-
assertUnhealthy(result, "critical", "pattern_loop");
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
// ── 3. Low character diversity ────────────────────────────────────
|
|
99
|
-
|
|
100
|
-
it("detects low character diversity", () => {
|
|
101
|
-
// 600 chars with only 10 unique chars, no pattern loop (10-char pattern doesn't match 2-6)
|
|
102
|
-
const text = "abcdefghij".repeat(60);
|
|
103
|
-
const result = checkOutputSanity(text);
|
|
104
|
-
assertUnhealthy(result, "critical", "low_diversity");
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("allows diverse text", () => {
|
|
108
|
-
const text =
|
|
109
|
-
"The quick brown fox jumps over the lazy dog. This sentence contains every letter of the alphabet at least once. ".repeat(
|
|
110
|
-
5,
|
|
111
|
-
);
|
|
112
|
-
const result = checkOutputSanity(text);
|
|
113
|
-
assertHealthy(result);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("does not flag short text (< 200 chars)", () => {
|
|
117
|
-
const text = "ab".repeat(99); // 198 chars, just under 200
|
|
118
|
-
assert.ok(text.length < 200, `Text must be < 200 chars (was ${text.length})`);
|
|
119
|
-
const result = checkOutputSanity(text);
|
|
120
|
-
// Should not have low_diversity pattern
|
|
121
|
-
assert.ok(result.isHealthy || result.patternName !== "low_diversity");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// ── 4. Visual gibberish / box drawing ─────────────────────────────
|
|
125
|
-
|
|
126
|
-
it("detects excessive box drawing characters", () => {
|
|
127
|
-
// Pure box art should match visual_gibberish (comes before low_diversity)
|
|
128
|
-
const boxArt = "┌─┐│└─┘├─┤┬┴┼".repeat(50);
|
|
129
|
-
const result = checkOutputSanity(boxArt);
|
|
130
|
-
assertUnhealthy(result, "critical", "visual_gibberish");
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("does not flag moderate box drawing in context", () => {
|
|
134
|
-
// Must be > 50 chars and not > 100 box chars with > 30% ratio
|
|
135
|
-
const text =
|
|
136
|
-
"┌───┐\n│ │\n└───┘\n" +
|
|
137
|
-
"Here is a simple diagram frame with plenty of surrounding context text " +
|
|
138
|
-
"that makes this string exceed 50 characters and avoids any pattern detection.";
|
|
139
|
-
assert.ok(text.length >= 50, `Test string must be >= 50 chars (was ${text.length})`);
|
|
140
|
-
const result = checkOutputSanity(text);
|
|
141
|
-
assertHealthy(result);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
// ── 5. CJK character spam ────────────────────────────────────────
|
|
145
|
-
|
|
146
|
-
it("detects CJK character spam", () => {
|
|
147
|
-
// Use a 10-char CJK string (avoids 2-6 char pattern loop) repeated
|
|
148
|
-
const cjkSpam = "天地玄黄宇宙洪荒日".repeat(40); // 400 CJK chars, 10 unique
|
|
149
|
-
const result = checkOutputSanity(cjkSpam);
|
|
150
|
-
assertUnhealthy(result, "critical", "cjk_spam");
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("allows legitimate CJK text", () => {
|
|
154
|
-
const cjkText = "这是一个正常的句子。它有各种各样的字符和不同的表达方式。".repeat(5);
|
|
155
|
-
const result = checkOutputSanity(cjkText);
|
|
156
|
-
assertHealthy(result);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// ── 6. Empty/tiny output ──────────────────────────────────────────
|
|
160
|
-
|
|
161
|
-
it("flags suspicious short output", () => {
|
|
162
|
-
const result = checkOutputSanity("x");
|
|
163
|
-
assertUnhealthy(result, "warning", "output_too_short");
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("allows single-word status output", () => {
|
|
167
|
-
assertHealthy(checkOutputSanity("ok"));
|
|
168
|
-
assertHealthy(checkOutputSanity("done"));
|
|
169
|
-
assertHealthy(checkOutputSanity("passed"));
|
|
170
|
-
assertHealthy(checkOutputSanity("true"));
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("allows numeric output", () => {
|
|
174
|
-
assertHealthy(checkOutputSanity("42"));
|
|
175
|
-
assertHealthy(checkOutputSanity("3.14159"));
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// ── 7. Error stack bleed ──────────────────────────────────────────
|
|
179
|
-
|
|
180
|
-
it("detects excessive error stack lines", () => {
|
|
181
|
-
const errorStack = [
|
|
182
|
-
"Error: something went wrong",
|
|
183
|
-
" at Object.<anonymous> (file.ts:10:5)",
|
|
184
|
-
" at Module._compile (module.js:653:30)",
|
|
185
|
-
" at Object.Module._extensions (module.js:664:10)",
|
|
186
|
-
" at Module.load (module.js:566:32)",
|
|
187
|
-
" at tryModuleLoad (module.js:506:12)",
|
|
188
|
-
].join("\n");
|
|
189
|
-
assert.ok(errorStack.length >= 50, `Test string must be >= 50 chars (was ${errorStack.length})`);
|
|
190
|
-
const result = checkOutputSanity(errorStack);
|
|
191
|
-
assertUnhealthy(result, "warning", "error_stack_bleed");
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it("allows normal error mentions", () => {
|
|
195
|
-
const text =
|
|
196
|
-
"We got an Error: not found, but handled it gracefully with fallback logic " +
|
|
197
|
-
"that continues execution without any problems whatsoever.";
|
|
198
|
-
const result = checkOutputSanity(text);
|
|
199
|
-
assertHealthy(result);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
// ── 8. Line-by-line repetition ────────────────────────────────────
|
|
203
|
-
|
|
204
|
-
it("detects excessive line repetition", () => {
|
|
205
|
-
// Use a line with many unique characters to avoid low_diversity
|
|
206
|
-
const line =
|
|
207
|
-
"Sphinx of black quartz, judge my vow! The five boxing wizards jump quickly. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
208
|
-
const repeatedLines = Array.from({ length: 20 }, () => line).join("\n");
|
|
209
|
-
const result = checkOutputSanity(repeatedLines);
|
|
210
|
-
assertUnhealthy(result, "warning", "line_repetition");
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it("allows normal line variation", () => {
|
|
214
|
-
const normalLines = [
|
|
215
|
-
"First line of unique content here for the test case.",
|
|
216
|
-
"Second line is different from all the rest.",
|
|
217
|
-
"Third line is also unique in its own way.",
|
|
218
|
-
"Fourth line continues the thought process forward.",
|
|
219
|
-
"Fifth line wraps up the opening section nicely.",
|
|
220
|
-
"Sixth line adds more context to the discussion.",
|
|
221
|
-
"Seventh line explores new ideas and concepts.",
|
|
222
|
-
"Eighth line approaches the question differently.",
|
|
223
|
-
"Ninth line concludes the main arguments well.",
|
|
224
|
-
"Tenth line is the final summary statement.",
|
|
225
|
-
"Eleventh line surprises everyone with extra depth.",
|
|
226
|
-
"Twelfth line brings the total to a solid dozen.",
|
|
227
|
-
].join("\n");
|
|
228
|
-
const result = checkOutputSanity(normalLines);
|
|
229
|
-
assertHealthy(result);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
// ── Mixed / edge cases ───────────────────────────────────────────
|
|
233
|
-
|
|
234
|
-
it("returns healthy for normal prose", () => {
|
|
235
|
-
const prose =
|
|
236
|
-
"This is a normal paragraph of text that should pass all sanity checks. " +
|
|
237
|
-
"It contains varied characters and meaningful content. The quick brown fox jumps over the lazy dog. " +
|
|
238
|
-
"No patterns of degeneration should be detected here.";
|
|
239
|
-
assertHealthy(checkOutputSanity(prose));
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it("flags empty string as warning", () => {
|
|
243
|
-
const result = checkOutputSanity("");
|
|
244
|
-
assertUnhealthy(result, "warning", "empty_output");
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it("flags null/undefined as critical", () => {
|
|
248
|
-
const r1 = checkOutputSanity(null);
|
|
249
|
-
assertUnhealthy(r1, "critical", "empty_output");
|
|
250
|
-
const r2 = checkOutputSanity(undefined);
|
|
251
|
-
assertUnhealthy(r2, "critical", "empty_output");
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it("detects multiple patterns (first match wins)", () => {
|
|
255
|
-
// Text with both pattern loop and low diversity — should report pattern_loop first
|
|
256
|
-
const degenerate = "ab".repeat(50) + " extra unique text that varies the output so it stays above fifty characters";
|
|
257
|
-
const result = checkOutputSanity(degenerate);
|
|
258
|
-
assertUnhealthy(result, "critical");
|
|
259
|
-
// Pattern loop should win since it comes first
|
|
260
|
-
assert.equal(result.patternName, "pattern_loop");
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
// ── AnomalyTracker tests ──────────────────────────────────────────────
|
|
265
|
-
|
|
266
|
-
describe("AnomalyTracker", () => {
|
|
267
|
-
let tracker: AnomalyTracker;
|
|
268
|
-
|
|
269
|
-
before(() => {
|
|
270
|
-
tracker = AnomalyTracker.getInstance();
|
|
271
|
-
tracker.resetAll();
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
after(() => {
|
|
275
|
-
tracker.resetAll();
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
// ── Recording ────────────────────────────────────────────────────
|
|
279
|
-
|
|
280
|
-
it("records healthy output — resets counter", () => {
|
|
281
|
-
const healthy: SanityResult = { isHealthy: true, severity: "ok" };
|
|
282
|
-
const result = tracker.record("s1", healthy);
|
|
283
|
-
assert.equal(result.shouldEscalate, false);
|
|
284
|
-
assert.equal(result.consecutiveAnomalies, 0);
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it("records single anomaly — no escalation", () => {
|
|
288
|
-
tracker.resetAll();
|
|
289
|
-
const unhealthy: SanityResult = {
|
|
290
|
-
isHealthy: false,
|
|
291
|
-
severity: "critical",
|
|
292
|
-
reason: "Single character repetition",
|
|
293
|
-
patternName: "single_char_repetition",
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
const result = tracker.record("s2", unhealthy);
|
|
297
|
-
assert.equal(result.shouldEscalate, false);
|
|
298
|
-
assert.equal(result.consecutiveAnomalies, 1);
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
it("triggers escalation on 2+ consecutive anomalies", () => {
|
|
302
|
-
tracker.resetAll();
|
|
303
|
-
const unhealthy: SanityResult = {
|
|
304
|
-
isHealthy: false,
|
|
305
|
-
severity: "critical",
|
|
306
|
-
reason: "Pattern loop detected",
|
|
307
|
-
patternName: "pattern_loop",
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
// First anomaly — no escalation
|
|
311
|
-
const first = tracker.record("s3", unhealthy);
|
|
312
|
-
assert.equal(first.shouldEscalate, false);
|
|
313
|
-
assert.equal(first.consecutiveAnomalies, 1);
|
|
314
|
-
|
|
315
|
-
// Second consecutive anomaly — escalation
|
|
316
|
-
const second = tracker.record("s3", unhealthy);
|
|
317
|
-
assert.equal(second.shouldEscalate, true);
|
|
318
|
-
assert.equal(second.consecutiveAnomalies, 2);
|
|
319
|
-
assert.ok(second.recoveryMessage, "recovery message should be present");
|
|
320
|
-
assert.equal(second.recoveryMessage, "recovery: compact context");
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it("resets counter on healthy output between anomalies", () => {
|
|
324
|
-
tracker.resetAll();
|
|
325
|
-
const unhealthy: SanityResult = {
|
|
326
|
-
isHealthy: false,
|
|
327
|
-
severity: "warning",
|
|
328
|
-
reason: "Output too short",
|
|
329
|
-
patternName: "output_too_short",
|
|
330
|
-
};
|
|
331
|
-
const healthy: SanityResult = { isHealthy: true, severity: "ok" };
|
|
332
|
-
|
|
333
|
-
tracker.record("s4", unhealthy); // count=1
|
|
334
|
-
tracker.record("s4", healthy); // reset to 0
|
|
335
|
-
const result = tracker.record("s4", unhealthy); // count=1 again
|
|
336
|
-
assert.equal(result.shouldEscalate, false);
|
|
337
|
-
assert.equal(result.consecutiveAnomalies, 1);
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
it("persists 3+ consecutive anomalies", () => {
|
|
341
|
-
tracker.resetAll();
|
|
342
|
-
const unhealthy: SanityResult = {
|
|
343
|
-
isHealthy: false,
|
|
344
|
-
severity: "warning",
|
|
345
|
-
reason: "Error stack bleed",
|
|
346
|
-
patternName: "error_stack_bleed",
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
tracker.record("s5", unhealthy); // 1
|
|
350
|
-
tracker.record("s5", unhealthy); // 2 → escalation
|
|
351
|
-
const third = tracker.record("s5", unhealthy); // 3 → escalation
|
|
352
|
-
assert.equal(third.shouldEscalate, true);
|
|
353
|
-
assert.equal(third.consecutiveAnomalies, 3);
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
// ── getRecord ────────────────────────────────────────────────────
|
|
357
|
-
|
|
358
|
-
it("getRecord() returns record for existing session", () => {
|
|
359
|
-
tracker.resetAll();
|
|
360
|
-
const unhealthy: SanityResult = {
|
|
361
|
-
isHealthy: false,
|
|
362
|
-
severity: "critical",
|
|
363
|
-
reason: "Low info density",
|
|
364
|
-
patternName: "low_diversity",
|
|
365
|
-
};
|
|
366
|
-
tracker.record("s6", unhealthy);
|
|
367
|
-
|
|
368
|
-
const record = tracker.getRecord("s6");
|
|
369
|
-
assert.ok(record);
|
|
370
|
-
assert.equal(record.count, 1);
|
|
371
|
-
assert.equal(record.lastReason, "Low info density");
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
it("getRecord() returns undefined for unknown session", () => {
|
|
375
|
-
const record = tracker.getRecord("nonexistent");
|
|
376
|
-
assert.equal(record, undefined);
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
// ── clearSession ──────────────────────────────────────────────────
|
|
380
|
-
|
|
381
|
-
it("clearSession() removes record for a session", () => {
|
|
382
|
-
tracker.resetAll();
|
|
383
|
-
const unhealthy: SanityResult = {
|
|
384
|
-
isHealthy: false,
|
|
385
|
-
severity: "critical",
|
|
386
|
-
reason: "Pattern loop",
|
|
387
|
-
patternName: "pattern_loop",
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
tracker.record("s7", unhealthy);
|
|
391
|
-
assert.ok(tracker.getRecord("s7"));
|
|
392
|
-
|
|
393
|
-
tracker.clearSession("s7");
|
|
394
|
-
assert.equal(tracker.getRecord("s7"), undefined);
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
it("clearSession() does not affect other sessions", () => {
|
|
398
|
-
tracker.resetAll();
|
|
399
|
-
const unhealthy: SanityResult = {
|
|
400
|
-
isHealthy: false,
|
|
401
|
-
severity: "critical",
|
|
402
|
-
reason: "Test",
|
|
403
|
-
patternName: "single_char_repetition",
|
|
404
|
-
};
|
|
405
|
-
|
|
406
|
-
tracker.record("s8_a", unhealthy);
|
|
407
|
-
tracker.record("s8_b", unhealthy);
|
|
408
|
-
|
|
409
|
-
tracker.clearSession("s8_a");
|
|
410
|
-
assert.equal(tracker.getRecord("s8_a"), undefined);
|
|
411
|
-
assert.ok(tracker.getRecord("s8_b"));
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
// ── resetAll ─────────────────────────────────────────────────────
|
|
415
|
-
|
|
416
|
-
it("resetAll() clears all records", () => {
|
|
417
|
-
const unhealthy: SanityResult = {
|
|
418
|
-
isHealthy: false,
|
|
419
|
-
severity: "critical",
|
|
420
|
-
reason: "Test",
|
|
421
|
-
patternName: "single_char_repetition",
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
tracker.record("s9", unhealthy);
|
|
425
|
-
tracker.resetAll();
|
|
426
|
-
assert.equal(tracker.getRecord("s9"), undefined);
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
// ── config ────────────────────────────────────────────────────────
|
|
430
|
-
|
|
431
|
-
it("uses configurable maxConsecutiveAnomalies", () => {
|
|
432
|
-
tracker.resetAll();
|
|
433
|
-
tracker.setConfig({ maxConsecutiveAnomalies: 3 });
|
|
434
|
-
|
|
435
|
-
const unhealthy: SanityResult = {
|
|
436
|
-
isHealthy: false,
|
|
437
|
-
severity: "warning",
|
|
438
|
-
reason: "Test threshold",
|
|
439
|
-
patternName: "output_too_short",
|
|
440
|
-
};
|
|
441
|
-
|
|
442
|
-
const first = tracker.record("s10", unhealthy); // 1
|
|
443
|
-
assert.equal(first.shouldEscalate, false);
|
|
444
|
-
|
|
445
|
-
const second = tracker.record("s10", unhealthy); // 2
|
|
446
|
-
assert.equal(second.shouldEscalate, false);
|
|
447
|
-
|
|
448
|
-
const third = tracker.record("s10", unhealthy); // 3 → escalation
|
|
449
|
-
assert.equal(third.shouldEscalate, true);
|
|
450
|
-
|
|
451
|
-
// Restore default
|
|
452
|
-
tracker.setConfig({ maxConsecutiveAnomalies: 2 });
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
it("uses configurable escalationMessage", () => {
|
|
456
|
-
tracker.resetAll();
|
|
457
|
-
tracker.setConfig({ escalationMessage: "custom recovery action: deep reset" });
|
|
458
|
-
|
|
459
|
-
const unhealthy: SanityResult = {
|
|
460
|
-
isHealthy: false,
|
|
461
|
-
severity: "critical",
|
|
462
|
-
reason: "Test message",
|
|
463
|
-
patternName: "pattern_loop",
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
tracker.record("s11", unhealthy); // 1
|
|
467
|
-
const result = tracker.record("s11", unhealthy); // 2 → escalation
|
|
468
|
-
assert.equal(result.recoveryMessage, "custom recovery action: deep reset");
|
|
469
|
-
|
|
470
|
-
tracker.setConfig({ escalationMessage: "recovery: compact context" }); // restore
|
|
471
|
-
});
|
|
472
|
-
});
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
// PlanFileWatcher — singleton that monitors plan files for external changes
|
|
2
|
-
// using fs.watch with debounced callbacks.
|
|
3
|
-
|
|
4
|
-
import fs from "node:fs";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
// Constants
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
|
|
11
|
-
const DEBOUNCE_MS = 500; // Debounce window for file-change events
|
|
12
|
-
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// PlanFileWatcher
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
|
|
17
|
-
export class PlanFileWatcher {
|
|
18
|
-
private static instance: PlanFileWatcher | null = null;
|
|
19
|
-
|
|
20
|
-
/** Active fs.FSWatcher instances keyed by directory path. */
|
|
21
|
-
private watchers = new Map<string, fs.FSWatcher>();
|
|
22
|
-
|
|
23
|
-
/** Registered callbacks keyed by directory path. */
|
|
24
|
-
private callbacks = new Map<string, (path: string) => void>();
|
|
25
|
-
|
|
26
|
-
/** Pending debounce timers keyed by directory path. */
|
|
27
|
-
private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
28
|
-
|
|
29
|
-
/** Whether change-notifications are paused. */
|
|
30
|
-
private _paused = false;
|
|
31
|
-
|
|
32
|
-
private constructor() {}
|
|
33
|
-
|
|
34
|
-
/** Get the singleton instance. */
|
|
35
|
-
static getInstance(): PlanFileWatcher {
|
|
36
|
-
if (!PlanFileWatcher.instance) {
|
|
37
|
-
PlanFileWatcher.instance = new PlanFileWatcher();
|
|
38
|
-
}
|
|
39
|
-
return PlanFileWatcher.instance;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Reset singleton — used in tests. */
|
|
43
|
-
static resetInstance(): void {
|
|
44
|
-
const inst = PlanFileWatcher.instance;
|
|
45
|
-
if (inst) {
|
|
46
|
-
inst.destroy();
|
|
47
|
-
PlanFileWatcher.instance = null;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// -----------------------------------------------------------------------
|
|
52
|
-
// Public API
|
|
53
|
-
// -----------------------------------------------------------------------
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Start watching `directory` for plan-file changes.
|
|
57
|
-
*
|
|
58
|
-
* When a change is detected, `callback` is invoked with the path of the
|
|
59
|
-
* changed file. Multiple events within DEBOUNCE_MS are coalesced.
|
|
60
|
-
*
|
|
61
|
-
* @param directory — absolute path to the directory containing plan files.
|
|
62
|
-
* @param callback — fired once per debounced change event.
|
|
63
|
-
*/
|
|
64
|
-
watch(directory: string, callback: (path: string) => void): void {
|
|
65
|
-
// Unwatch first if already watching this directory (re-registration)
|
|
66
|
-
if (this.watchers.has(directory)) {
|
|
67
|
-
this.unwatch(directory);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
this.callbacks.set(directory, callback);
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
const watcher = fs.watch(
|
|
74
|
-
directory,
|
|
75
|
-
{ recursive: false },
|
|
76
|
-
(eventType: string, filename: string | null) => {
|
|
77
|
-
// fs.watch may pass null filename on some platforms
|
|
78
|
-
const targetPath = filename ? path.join(directory, filename) : directory;
|
|
79
|
-
|
|
80
|
-
// Debounce — cancel any pending timer, schedule a new one.
|
|
81
|
-
// The _paused check happens at fire time, not here, so events
|
|
82
|
-
// received during pause are debounced and fire on resume.
|
|
83
|
-
const existing = this.debounceTimers.get(directory);
|
|
84
|
-
if (existing) clearTimeout(existing);
|
|
85
|
-
|
|
86
|
-
this.debounceTimers.set(
|
|
87
|
-
directory,
|
|
88
|
-
setTimeout(() => {
|
|
89
|
-
this.debounceTimers.delete(directory);
|
|
90
|
-
const cb = this.callbacks.get(directory);
|
|
91
|
-
if (cb && !this._paused) {
|
|
92
|
-
cb(targetPath);
|
|
93
|
-
}
|
|
94
|
-
}, DEBOUNCE_MS),
|
|
95
|
-
);
|
|
96
|
-
},
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
this.watchers.set(directory, watcher);
|
|
100
|
-
} catch (err) {
|
|
101
|
-
// fs.watch can throw if directory doesn't exist or permissions issues
|
|
102
|
-
console.error(`[PlanFileWatcher] Failed to watch "${directory}":`, err);
|
|
103
|
-
this.watchers.delete(directory);
|
|
104
|
-
this.callbacks.delete(directory);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Stop watching `directory`.
|
|
110
|
-
*/
|
|
111
|
-
unwatch(directory: string): void {
|
|
112
|
-
this.clearDebounce(directory);
|
|
113
|
-
|
|
114
|
-
const watcher = this.watchers.get(directory);
|
|
115
|
-
if (watcher) {
|
|
116
|
-
watcher.close();
|
|
117
|
-
this.watchers.delete(directory);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
this.callbacks.delete(directory);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Pause change-notification without losing watch-registrations.
|
|
125
|
-
* While paused, events are still debounced but callbacks are suppressed.
|
|
126
|
-
* Missed changes are not queued or replayed on resume.
|
|
127
|
-
*/
|
|
128
|
-
pause(): void {
|
|
129
|
-
this._paused = true;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Resume change-notification after a pause.
|
|
134
|
-
* Only future events will notify callbacks.
|
|
135
|
-
*/
|
|
136
|
-
resume(): void {
|
|
137
|
-
this._paused = false;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Check if the watcher is currently paused. */
|
|
141
|
-
get paused(): boolean {
|
|
142
|
-
return this._paused;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Return the list of currently watched directories. */
|
|
146
|
-
watchedDirectories(): string[] {
|
|
147
|
-
return Array.from(this.watchers.keys());
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// -----------------------------------------------------------------------
|
|
151
|
-
// Lifecycle
|
|
152
|
-
// -----------------------------------------------------------------------
|
|
153
|
-
|
|
154
|
-
/** Shut down all watchers and clear state. */
|
|
155
|
-
destroy(): void {
|
|
156
|
-
for (const [dir] of this.watchers) {
|
|
157
|
-
this.unwatch(dir);
|
|
158
|
-
}
|
|
159
|
-
this.debounceTimers.clear();
|
|
160
|
-
this.callbacks.clear();
|
|
161
|
-
this._paused = false;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// -----------------------------------------------------------------------
|
|
165
|
-
// Internals
|
|
166
|
-
// -----------------------------------------------------------------------
|
|
167
|
-
|
|
168
|
-
private clearDebounce(directory: string): void {
|
|
169
|
-
const timer = this.debounceTimers.get(directory);
|
|
170
|
-
if (timer) {
|
|
171
|
-
clearTimeout(timer);
|
|
172
|
-
this.debounceTimers.delete(directory);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
// MVCC-Style Plan Synchronization — barrel export.
|
|
2
|
-
|
|
3
|
-
export type {
|
|
4
|
-
SyncPlanEntry,
|
|
5
|
-
PlanSyncState,
|
|
6
|
-
SyncConflict,
|
|
7
|
-
ConflictStrategy,
|
|
8
|
-
} from "./interfaces.ts";
|
|
9
|
-
|
|
10
|
-
export { PlanSync } from "./plan-sync.ts";
|
|
11
|
-
export { PlanFileWatcher } from "./file-watcher.ts";
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
// MVCC-Style Plan Synchronization — type definitions for concurrent-safe plan file access.
|
|
2
|
-
|
|
3
|
-
export interface SyncPlanEntry {
|
|
4
|
-
id: string;
|
|
5
|
-
description: string;
|
|
6
|
-
status: 'pending' | 'in_progress' | 'completed' | 'blocked' | 'cancelled';
|
|
7
|
-
agent?: string;
|
|
8
|
-
timestamp: number;
|
|
9
|
-
version: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface PlanSyncState {
|
|
13
|
-
entries: Map<string, SyncPlanEntry>;
|
|
14
|
-
version: number; // global version counter
|
|
15
|
-
lastWriter: string; // agent/session that last wrote
|
|
16
|
-
lastWriteTime: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface SyncConflict {
|
|
20
|
-
entryId: string;
|
|
21
|
-
localVersion: number;
|
|
22
|
-
remoteVersion: number;
|
|
23
|
-
localStatus: string;
|
|
24
|
-
remoteStatus: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export type ConflictStrategy = 'last-writer-wins' | 'manual';
|