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.
Files changed (73) hide show
  1. package/CONTEXT.md +6 -6
  2. package/ETHOS.md +2 -2
  3. package/README.md +11 -17
  4. package/bootstrap.ts +118 -126
  5. package/docs/HOW-IT-WORKS.md +162 -0
  6. package/docs/adr/ADR-0001-rebuild-vs-increment.md +30 -0
  7. package/docs/adr/ADR-0002-routing-graph-vs-linear-chain.md +36 -0
  8. package/docs/adr/ADR-0003-per-directory-plan-storage.md +34 -0
  9. package/docs/adr/ADR-0004-composer-fragment-architecture.md +42 -0
  10. package/docs/adr/ADR-0005-hook-system-design.md +42 -0
  11. package/docs/adr/README.md +9 -0
  12. package/harness/codex/AUTOPILOT.md +35 -40
  13. package/harness/codex/CHARTER.md +3 -3
  14. package/harness/lib/composer/compose.test.ts +29 -29
  15. package/harness/lib/composer/fragments/02-delegation.md +5 -5
  16. package/harness/lib/composer/fragments/04-task-flow.md +13 -13
  17. package/harness/lib/composer/fragments/08-routing.md +1 -1
  18. package/harness/lib/composer/fragments/09-guardrails.md +25 -25
  19. package/harness/lib/composer/index.ts +1 -1
  20. package/harness/lib/guards/guard-config.ts +72 -72
  21. package/harness/lib/hooks/builtins/confidence-gate-hook.ts +9 -9
  22. package/harness/lib/hooks/builtins/delegation-depth-hook.ts +1 -1
  23. package/harness/lib/hooks/builtins/dynamic-route-hook.ts +99 -99
  24. package/harness/lib/hooks/builtins/next-route-hook.ts +24 -24
  25. package/harness/lib/hooks/builtins/plan-check-hook.ts +5 -5
  26. package/harness/lib/hooks/builtins/route-tracking-hook.ts +1 -1
  27. package/harness/lib/hooks/hooks.test.ts +160 -324
  28. package/harness/lib/hooks/index.ts +38 -42
  29. package/harness/lib/hooks/registry.ts +309 -416
  30. package/harness/lib/hooks/types.ts +116 -119
  31. package/harness/lib/plans/plan-location.ts +134 -134
  32. package/harness/lib/routing/index.ts +21 -21
  33. package/harness/lib/routing/route-guidance.ts +147 -147
  34. package/harness/lib/routing/route-resolver.ts +58 -58
  35. package/harness/lib/routing/routing.test.ts +195 -195
  36. package/harness/lib/routing/skill-frontmatter.ts +125 -125
  37. package/harness/lib/routing/types.ts +52 -52
  38. package/harness/skills/oh-ascii/SKILL.md +1 -1
  39. package/harness/skills/oh-fusion/DEEP.md +109 -109
  40. package/harness/skills/oh-fusion/SKILL.md +47 -47
  41. package/harness/skills/oh-init/DEEP.md +2 -2
  42. package/harness/skills/oh-plan-review/DEEP.md +1 -1
  43. package/harness/skills/oh-planner/DEEP.md +3 -3
  44. package/harness/skills/oh-review/DEEP.md +5 -5
  45. package/package.json +56 -53
  46. package/harness/lib/background/background.test.ts +0 -216
  47. package/harness/lib/background/index.ts +0 -7
  48. package/harness/lib/background/interfaces.ts +0 -31
  49. package/harness/lib/background/manager.ts +0 -320
  50. package/harness/lib/hooks/builtins/error-recovery-hook.ts +0 -107
  51. package/harness/lib/hooks/builtins/memory-sync-hook.ts +0 -73
  52. package/harness/lib/hooks/builtins/sanity-check-hook.ts +0 -52
  53. package/harness/lib/hooks/builtins/subagent-failure-hook.ts +0 -93
  54. package/harness/lib/memory/index.ts +0 -18
  55. package/harness/lib/memory/interfaces.ts +0 -53
  56. package/harness/lib/memory/memory-manager.ts +0 -205
  57. package/harness/lib/memory/memory.test.ts +0 -485
  58. package/harness/lib/memory/plan-store.ts +0 -346
  59. package/harness/lib/recovery/handler.ts +0 -243
  60. package/harness/lib/recovery/index.ts +0 -14
  61. package/harness/lib/recovery/interfaces.ts +0 -48
  62. package/harness/lib/recovery/patterns.ts +0 -149
  63. package/harness/lib/recovery/recovery.test.ts +0 -312
  64. package/harness/lib/sanity/anomaly-tracker.ts +0 -127
  65. package/harness/lib/sanity/checker.ts +0 -189
  66. package/harness/lib/sanity/index.ts +0 -13
  67. package/harness/lib/sanity/interfaces.ts +0 -24
  68. package/harness/lib/sanity/sanity.test.ts +0 -472
  69. package/harness/lib/sync/file-watcher.ts +0 -175
  70. package/harness/lib/sync/index.ts +0 -11
  71. package/harness/lib/sync/interfaces.ts +0 -27
  72. package/harness/lib/sync/plan-sync.ts +0 -533
  73. 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';