openhermes 4.9.2 → 4.11.2
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 +1 -1
- package/README.md +32 -31
- package/bootstrap.ts +262 -45
- package/harness/agents/oh-planner.md +1 -1
- package/harness/agents/openhermes.md +27 -126
- package/harness/codex/AUTOPILOT.md +99 -3
- package/harness/codex/CHARTER.md +3 -4
- package/harness/lib/background/background.test.ts +197 -0
- package/harness/lib/background/index.ts +7 -0
- package/harness/lib/background/interfaces.ts +31 -0
- package/harness/lib/background/manager.ts +320 -0
- package/harness/lib/composer/compose.test.ts +168 -0
- package/harness/lib/composer/compose.ts +65 -0
- package/harness/lib/composer/fragments/01-identity.md +1 -0
- package/harness/lib/composer/fragments/02-delegation.md +6 -0
- package/harness/lib/composer/fragments/03-permissions.md +13 -0
- package/harness/lib/composer/fragments/04-task-flow.md +15 -0
- package/harness/lib/composer/fragments/05-confidence.md +5 -0
- package/harness/lib/composer/fragments/06-parallelization.md +17 -0
- package/harness/lib/composer/fragments/07-shell.md +41 -0
- package/harness/lib/composer/fragments/08-routing.md +8 -0
- package/harness/lib/composer/fragments/09-guardrails.md +12 -0
- package/harness/lib/composer/index.ts +1 -0
- package/harness/lib/hooks/builtins/confidence-gate-hook.ts +70 -0
- package/harness/lib/hooks/builtins/delegation-depth-hook.ts +59 -0
- package/harness/lib/hooks/builtins/error-recovery-hook.ts +107 -0
- package/harness/lib/hooks/builtins/memory-sync-hook.ts +73 -0
- package/harness/lib/hooks/builtins/plan-check-hook.ts +43 -0
- package/harness/lib/hooks/builtins/route-tracking-hook.ts +147 -0
- package/harness/lib/hooks/builtins/sanity-check-hook.ts +52 -0
- package/harness/lib/hooks/builtins/shell-detect-hook.ts +96 -0
- package/harness/lib/hooks/hooks.test.ts +1016 -0
- package/harness/lib/hooks/index.ts +30 -0
- package/harness/lib/hooks/registry.ts +416 -0
- package/harness/lib/hooks/types.ts +71 -0
- package/harness/lib/memory/index.ts +18 -0
- package/harness/lib/memory/interfaces.ts +53 -0
- package/harness/lib/memory/memory-manager.ts +205 -0
- package/harness/lib/memory/memory.test.ts +491 -0
- package/harness/lib/memory/plan-store.ts +366 -0
- package/harness/lib/recovery/handler.ts +243 -0
- package/harness/lib/recovery/index.ts +14 -0
- package/harness/lib/recovery/interfaces.ts +48 -0
- package/harness/lib/recovery/patterns.ts +149 -0
- package/harness/lib/recovery/recovery.test.ts +312 -0
- package/harness/lib/sanity/anomaly-tracker.ts +127 -0
- package/harness/lib/sanity/checker.ts +178 -0
- package/harness/lib/sanity/index.ts +13 -0
- package/harness/lib/sanity/interfaces.ts +24 -0
- package/harness/lib/sanity/sanity.test.ts +472 -0
- package/harness/lib/sync/file-watcher.ts +174 -0
- package/harness/lib/sync/index.ts +11 -0
- package/harness/lib/sync/interfaces.ts +27 -0
- package/harness/lib/sync/plan-sync.ts +536 -0
- package/harness/lib/sync/sync.test.ts +832 -0
- package/harness/skills/oh-init/DEEP.md +2 -2
- package/harness/skills/oh-manifest/SKILL.md +1 -1
- package/harness/skills/oh-plan-review/DEEP.md +1 -1
- package/harness/skills/oh-planner/DEEP.md +3 -3
- package/harness/skills/oh-ship/SKILL.md +1 -1
- package/harness/skills/oh-skill-craft/SKILL.md +1 -4
- package/package.json +5 -5
- package/tsconfig.json +1 -1
- package/harness/commands/oh-doctor.md +0 -205
- package/harness/commands/oh-log.md +0 -18
- package/harness/skills/oh-learn/DEEP.md +0 -44
- package/harness/skills/oh-learn/SKILL.md +0 -30
- package/scripts/count-tokens.mjs +0 -158
- package/scripts/oh-doctor.ps1 +0 -342
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
import { describe, it, afterEach, before } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { PlanSync } from "./plan-sync.ts";
|
|
7
|
+
import { PlanFileWatcher } from "./file-watcher.ts";
|
|
8
|
+
import type { SyncPlanEntry, PlanSyncState } from "./interfaces.ts";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** Sleep for ms. */
|
|
15
|
+
function delay(ms: number): Promise<void> {
|
|
16
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Build a minimal test plan markdown with metadata header + Tasks section. */
|
|
20
|
+
function makePlanContent(
|
|
21
|
+
entries: Array<{
|
|
22
|
+
num: number;
|
|
23
|
+
title: string;
|
|
24
|
+
status?: "pending" | "in_progress" | "completed" | "blocked" | "cancelled";
|
|
25
|
+
checkboxes?: string[]; // " " or "x"
|
|
26
|
+
}>,
|
|
27
|
+
options?: {
|
|
28
|
+
syncVersion?: number;
|
|
29
|
+
lastWriter?: string;
|
|
30
|
+
lastWriteTime?: number;
|
|
31
|
+
activeTaskNum?: number;
|
|
32
|
+
completed?: number[];
|
|
33
|
+
extraSections?: string;
|
|
34
|
+
},
|
|
35
|
+
): string {
|
|
36
|
+
const lines: string[] = [];
|
|
37
|
+
|
|
38
|
+
// Metadata header
|
|
39
|
+
lines.push("# Test Plan");
|
|
40
|
+
lines.push("");
|
|
41
|
+
lines.push(`Sync-Version: ${options?.syncVersion ?? 1}`);
|
|
42
|
+
lines.push(`Last-Writer: ${options?.lastWriter ?? "test"}`);
|
|
43
|
+
lines.push(`Last-Write-Time: ${options?.lastWriteTime ?? "1700000000000"}`);
|
|
44
|
+
for (const e of entries) {
|
|
45
|
+
lines.push(`entry-task-${e.num}-version: 1`);
|
|
46
|
+
}
|
|
47
|
+
lines.push("");
|
|
48
|
+
lines.push("---");
|
|
49
|
+
lines.push("");
|
|
50
|
+
|
|
51
|
+
// Tasks section
|
|
52
|
+
lines.push("## Tasks");
|
|
53
|
+
lines.push("");
|
|
54
|
+
|
|
55
|
+
for (const e of entries) {
|
|
56
|
+
lines.push(`### Task ${e.num}: ${e.title}`);
|
|
57
|
+
lines.push(`**Effort:** 1 day`);
|
|
58
|
+
lines.push(`**Dependencies:** None`);
|
|
59
|
+
lines.push(`**Description:** Task ${e.num} description.`);
|
|
60
|
+
lines.push("");
|
|
61
|
+
lines.push("**Implementation:**");
|
|
62
|
+
lines.push(`1. Do step 1 for task ${e.num}`);
|
|
63
|
+
lines.push(`2. Do step 2 for task ${e.num}`);
|
|
64
|
+
lines.push("");
|
|
65
|
+
lines.push("**Success criteria:**");
|
|
66
|
+
|
|
67
|
+
const cbs = e.checkboxes ?? [" ", " "];
|
|
68
|
+
for (const cb of cbs) {
|
|
69
|
+
lines.push(`- [${cb}] Criterion for task ${e.num}`);
|
|
70
|
+
}
|
|
71
|
+
lines.push("");
|
|
72
|
+
lines.push("---");
|
|
73
|
+
lines.push("");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Optional sections
|
|
77
|
+
if (options?.activeTaskNum !== undefined) {
|
|
78
|
+
lines.push("## Active Task");
|
|
79
|
+
lines.push("");
|
|
80
|
+
const active = entries.find((e) => e.num === options.activeTaskNum);
|
|
81
|
+
if (active) {
|
|
82
|
+
lines.push(`**Task ${active.num}: ${active.title}**`);
|
|
83
|
+
lines.push("⚠️ IN PROGRESS");
|
|
84
|
+
}
|
|
85
|
+
lines.push("");
|
|
86
|
+
lines.push("---");
|
|
87
|
+
lines.push("");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (options?.completed && options.completed.length > 0) {
|
|
91
|
+
lines.push("## Completed");
|
|
92
|
+
lines.push("");
|
|
93
|
+
for (const num of options.completed) {
|
|
94
|
+
const e = entries.find((en) => en.num === num);
|
|
95
|
+
if (e) {
|
|
96
|
+
lines.push(`- [x] Task ${num}: ${e.title}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
lines.push("");
|
|
100
|
+
lines.push("---");
|
|
101
|
+
lines.push("");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (options?.extraSections) {
|
|
105
|
+
lines.push(options.extraSections);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create a temporary directory with a plan file, returning cleanup function.
|
|
113
|
+
*/
|
|
114
|
+
async function createTestPlan(
|
|
115
|
+
content: string,
|
|
116
|
+
): Promise<{ dir: string; filePath: string; cleanup: () => Promise<void> }> {
|
|
117
|
+
const tmpDir = path.join(os.tmpdir(), `sync-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
118
|
+
await fs.promises.mkdir(tmpDir, { recursive: true });
|
|
119
|
+
const filePath = path.join(tmpDir, "plan-001.md");
|
|
120
|
+
await fs.promises.writeFile(filePath, content, "utf8");
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
dir: tmpDir,
|
|
124
|
+
filePath,
|
|
125
|
+
cleanup: async () => {
|
|
126
|
+
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Tests
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
describe("PlanSync — parsing", () => {
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
PlanSync.resetInstance();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("readPlanState parses a plan file with tasks", async () => {
|
|
141
|
+
const content = makePlanContent([
|
|
142
|
+
{ num: 1, title: "Setup Infrastructure", checkboxes: ["x", "x"] },
|
|
143
|
+
{ num: 2, title: "Build Feature", checkboxes: [" ", " "] },
|
|
144
|
+
]);
|
|
145
|
+
const { filePath, cleanup } = await createTestPlan(content);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const sync = PlanSync.getInstance();
|
|
149
|
+
const state = await sync.readPlanState(filePath);
|
|
150
|
+
|
|
151
|
+
assert.equal(state.version, 1);
|
|
152
|
+
assert.equal(state.lastWriter, "test");
|
|
153
|
+
assert.equal(state.entries.size, 2);
|
|
154
|
+
|
|
155
|
+
const task1 = state.entries.get("task-1");
|
|
156
|
+
assert.ok(task1, "task-1 must exist");
|
|
157
|
+
assert.equal(task1.description, "Setup Infrastructure");
|
|
158
|
+
assert.equal(task1.version, 1);
|
|
159
|
+
|
|
160
|
+
const task2 = state.entries.get("task-2");
|
|
161
|
+
assert.ok(task2, "task-2 must exist");
|
|
162
|
+
assert.equal(task2.description, "Build Feature");
|
|
163
|
+
} finally {
|
|
164
|
+
await cleanup();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("detects status from checkboxes (all [x] → completed)", async () => {
|
|
169
|
+
const content = makePlanContent([
|
|
170
|
+
{ num: 1, title: "Done Task", checkboxes: ["x", "x", "x"] },
|
|
171
|
+
]);
|
|
172
|
+
const { filePath, cleanup } = await createTestPlan(content);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const sync = PlanSync.getInstance();
|
|
176
|
+
const state = await sync.readPlanState(filePath);
|
|
177
|
+
const task1 = state.entries.get("task-1");
|
|
178
|
+
assert.equal(task1?.status, "completed");
|
|
179
|
+
} finally {
|
|
180
|
+
await cleanup();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("detects status from checkboxes (no [x] → pending)", async () => {
|
|
185
|
+
const content = makePlanContent([
|
|
186
|
+
{ num: 1, title: "Pending Task", checkboxes: [" ", " "] },
|
|
187
|
+
]);
|
|
188
|
+
const { filePath, cleanup } = await createTestPlan(content);
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const sync = PlanSync.getInstance();
|
|
192
|
+
const state = await sync.readPlanState(filePath);
|
|
193
|
+
const task1 = state.entries.get("task-1");
|
|
194
|
+
assert.equal(task1?.status, "pending");
|
|
195
|
+
} finally {
|
|
196
|
+
await cleanup();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("detects active task status from ## Active Task section", async () => {
|
|
201
|
+
const content = makePlanContent(
|
|
202
|
+
[
|
|
203
|
+
{ num: 1, title: "Setup", checkboxes: ["x"] },
|
|
204
|
+
{ num: 2, title: "In Progress Task", checkboxes: [" ", " "] },
|
|
205
|
+
{ num: 3, title: "Future", checkboxes: [" ", " "] },
|
|
206
|
+
],
|
|
207
|
+
{ activeTaskNum: 2 },
|
|
208
|
+
);
|
|
209
|
+
const { filePath, cleanup } = await createTestPlan(content);
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const sync = PlanSync.getInstance();
|
|
213
|
+
const state = await sync.readPlanState(filePath);
|
|
214
|
+
const task2 = state.entries.get("task-2");
|
|
215
|
+
assert.equal(task2?.status, "in_progress");
|
|
216
|
+
} finally {
|
|
217
|
+
await cleanup();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("detects completed status from ## Completed section", async () => {
|
|
222
|
+
const content = makePlanContent(
|
|
223
|
+
[
|
|
224
|
+
{ num: 1, title: "Done", checkboxes: [" "] },
|
|
225
|
+
{ num: 2, title: "Next", checkboxes: [" "] },
|
|
226
|
+
],
|
|
227
|
+
{ completed: [1] },
|
|
228
|
+
);
|
|
229
|
+
const { filePath, cleanup } = await createTestPlan(content);
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const sync = PlanSync.getInstance();
|
|
233
|
+
const state = await sync.readPlanState(filePath);
|
|
234
|
+
const task1 = state.entries.get("task-1");
|
|
235
|
+
assert.equal(task1?.status, "completed");
|
|
236
|
+
} finally {
|
|
237
|
+
await cleanup();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("detects blocked status from keyword in task block", async () => {
|
|
242
|
+
// Use empty checkboxes array to avoid default unchecked ones
|
|
243
|
+
const content = makePlanContent([
|
|
244
|
+
{ num: 1, title: "Blocked Task", checkboxes: [] },
|
|
245
|
+
]);
|
|
246
|
+
// Inject "blocked" keyword into the description line
|
|
247
|
+
const blockedContent = content.replace(
|
|
248
|
+
"**Description:** Task 1 description.",
|
|
249
|
+
"**Description:** Task 1 description. This task is blocked by external dependency.",
|
|
250
|
+
);
|
|
251
|
+
const { filePath, cleanup } = await createTestPlan(blockedContent);
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const sync = PlanSync.getInstance();
|
|
255
|
+
const state = await sync.readPlanState(filePath);
|
|
256
|
+
const task1 = state.entries.get("task-1");
|
|
257
|
+
assert.equal(task1?.status, "blocked");
|
|
258
|
+
} finally {
|
|
259
|
+
await cleanup();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
describe("PlanSync — atomic writes", () => {
|
|
267
|
+
afterEach(() => {
|
|
268
|
+
PlanSync.resetInstance();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("writePlanState writes content to disk", async () => {
|
|
272
|
+
const content = makePlanContent([{ num: 1, title: "Test Task" }]);
|
|
273
|
+
const { filePath, cleanup } = await createTestPlan(content);
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const sync = PlanSync.getInstance();
|
|
277
|
+
const state = await sync.readPlanState(filePath);
|
|
278
|
+
state.lastWriter = "test-agent";
|
|
279
|
+
|
|
280
|
+
await sync.writePlanState(filePath, state);
|
|
281
|
+
|
|
282
|
+
const written = await fs.promises.readFile(filePath, "utf8");
|
|
283
|
+
assert.ok(written.includes("Sync-Version: 1"), "must contain Sync-Version");
|
|
284
|
+
assert.ok(written.includes("Last-Writer: test-agent"), "must contain Last-Writer");
|
|
285
|
+
} finally {
|
|
286
|
+
await cleanup();
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("atomic write cleans up temp file", async () => {
|
|
291
|
+
const content = makePlanContent([{ num: 1, title: "Temp Cleanup" }]);
|
|
292
|
+
const { dir, filePath, cleanup } = await createTestPlan(content);
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const sync = PlanSync.getInstance();
|
|
296
|
+
const state = await sync.readPlanState(filePath);
|
|
297
|
+
await sync.writePlanState(filePath, state);
|
|
298
|
+
|
|
299
|
+
// No temp files should remain
|
|
300
|
+
const files = await fs.promises.readdir(dir);
|
|
301
|
+
const tmpFiles = files.filter((f) => f.endsWith(".tmp"));
|
|
302
|
+
assert.equal(tmpFiles.length, 0, `temp files must be cleaned up after write, found: ${tmpFiles.join(", ")}`);
|
|
303
|
+
} finally {
|
|
304
|
+
await cleanup();
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("atomic write does not corrupt data on failure", async () => {
|
|
309
|
+
const content = makePlanContent([{ num: 1, title: "Resilient Task" }]);
|
|
310
|
+
const { filePath, cleanup } = await createTestPlan(content);
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const sync = PlanSync.getInstance();
|
|
314
|
+
const originalContent = await fs.promises.readFile(filePath, "utf8");
|
|
315
|
+
|
|
316
|
+
// Write a valid state
|
|
317
|
+
const state = await sync.readPlanState(filePath);
|
|
318
|
+
await sync.writePlanState(filePath, state);
|
|
319
|
+
|
|
320
|
+
// File should still be valid markdown
|
|
321
|
+
const after = await fs.promises.readFile(filePath, "utf8");
|
|
322
|
+
assert.ok(after.length > 0, "file should not be empty");
|
|
323
|
+
assert.ok(after.includes("Test Plan"), "file should contain plan content");
|
|
324
|
+
} finally {
|
|
325
|
+
await cleanup();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
describe("PlanSync — updateEntry & versioning", () => {
|
|
333
|
+
afterEach(() => {
|
|
334
|
+
PlanSync.resetInstance();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("updateEntry increments version on the entry", async () => {
|
|
338
|
+
const content = makePlanContent([{ num: 1, title: "Versioned Task" }]);
|
|
339
|
+
const { filePath, cleanup } = await createTestPlan(content);
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const sync = PlanSync.getInstance();
|
|
343
|
+
|
|
344
|
+
const update: SyncPlanEntry = {
|
|
345
|
+
id: "task-1",
|
|
346
|
+
description: "Versioned Task (updated)",
|
|
347
|
+
status: "in_progress",
|
|
348
|
+
agent: "agent-x",
|
|
349
|
+
timestamp: Date.now(),
|
|
350
|
+
version: 1,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
await sync.updateEntry(filePath, update);
|
|
354
|
+
|
|
355
|
+
const state = await sync.readPlanState(filePath);
|
|
356
|
+
const task1 = state.entries.get("task-1");
|
|
357
|
+
assert.ok(task1, "task must exist after update");
|
|
358
|
+
assert.ok(task1.version >= 2, `expected version >= 2, got ${task1.version}`);
|
|
359
|
+
} finally {
|
|
360
|
+
await cleanup();
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("updateEntry changes status correctly", async () => {
|
|
365
|
+
const content = makePlanContent([{ num: 1, title: "Status Change" }]);
|
|
366
|
+
const { filePath, cleanup } = await createTestPlan(content);
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const sync = PlanSync.getInstance();
|
|
370
|
+
|
|
371
|
+
// Initially pending
|
|
372
|
+
let state = await sync.readPlanState(filePath);
|
|
373
|
+
assert.equal(state.entries.get("task-1")?.status, "pending");
|
|
374
|
+
|
|
375
|
+
// Update to in_progress
|
|
376
|
+
await sync.updateEntry(filePath, {
|
|
377
|
+
id: "task-1",
|
|
378
|
+
description: "Status Change",
|
|
379
|
+
status: "in_progress",
|
|
380
|
+
agent: "agent-y",
|
|
381
|
+
timestamp: Date.now(),
|
|
382
|
+
version: 1,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
state = await sync.readPlanState(filePath);
|
|
386
|
+
assert.equal(state.entries.get("task-1")?.status, "in_progress");
|
|
387
|
+
} finally {
|
|
388
|
+
await cleanup();
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
describe("PlanSync — conflict detection & resolution", () => {
|
|
396
|
+
afterEach(() => {
|
|
397
|
+
PlanSync.resetInstance();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("detectConflicts returns empty for identical states", () => {
|
|
401
|
+
const sync = PlanSync.getInstance();
|
|
402
|
+
|
|
403
|
+
const localState: PlanSyncState = {
|
|
404
|
+
entries: new Map([
|
|
405
|
+
[
|
|
406
|
+
"task-1",
|
|
407
|
+
{ id: "task-1", description: "Task 1", status: "pending", timestamp: 100, version: 1 },
|
|
408
|
+
],
|
|
409
|
+
]),
|
|
410
|
+
version: 1,
|
|
411
|
+
lastWriter: "a",
|
|
412
|
+
lastWriteTime: 100,
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const remoteState: PlanSyncState = {
|
|
416
|
+
entries: new Map([
|
|
417
|
+
[
|
|
418
|
+
"task-1",
|
|
419
|
+
{ id: "task-1", description: "Task 1", status: "pending", timestamp: 100, version: 1 },
|
|
420
|
+
],
|
|
421
|
+
]),
|
|
422
|
+
version: 1,
|
|
423
|
+
lastWriter: "a",
|
|
424
|
+
lastWriteTime: 100,
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const conflicts = sync.detectConflicts(localState, remoteState);
|
|
428
|
+
assert.equal(conflicts.length, 0, "identical states should have no conflicts");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("detectConflicts finds version mismatches", () => {
|
|
432
|
+
const sync = PlanSync.getInstance();
|
|
433
|
+
|
|
434
|
+
const localState: PlanSyncState = {
|
|
435
|
+
entries: new Map([
|
|
436
|
+
[
|
|
437
|
+
"task-1",
|
|
438
|
+
{ id: "task-1", description: "Task 1", status: "pending", timestamp: 100, version: 2 },
|
|
439
|
+
],
|
|
440
|
+
]),
|
|
441
|
+
version: 2,
|
|
442
|
+
lastWriter: "a",
|
|
443
|
+
lastWriteTime: 200,
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const remoteState: PlanSyncState = {
|
|
447
|
+
entries: new Map([
|
|
448
|
+
[
|
|
449
|
+
"task-1",
|
|
450
|
+
{ id: "task-1", description: "Task 1", status: "completed", timestamp: 150, version: 3 },
|
|
451
|
+
],
|
|
452
|
+
]),
|
|
453
|
+
version: 3,
|
|
454
|
+
lastWriter: "b",
|
|
455
|
+
lastWriteTime: 300,
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const conflicts = sync.detectConflicts(localState, remoteState);
|
|
459
|
+
assert.equal(conflicts.length, 1);
|
|
460
|
+
assert.equal(conflicts[0].entryId, "task-1");
|
|
461
|
+
assert.equal(conflicts[0].localVersion, 2);
|
|
462
|
+
assert.equal(conflicts[0].remoteVersion, 3);
|
|
463
|
+
assert.equal(conflicts[0].localStatus, "pending");
|
|
464
|
+
assert.equal(conflicts[0].remoteStatus, "completed");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("detectConflicts finds missing entries", () => {
|
|
468
|
+
const sync = PlanSync.getInstance();
|
|
469
|
+
|
|
470
|
+
const localState: PlanSyncState = {
|
|
471
|
+
entries: new Map(),
|
|
472
|
+
version: 1,
|
|
473
|
+
lastWriter: "a",
|
|
474
|
+
lastWriteTime: 100,
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const remoteState: PlanSyncState = {
|
|
478
|
+
entries: new Map([
|
|
479
|
+
[
|
|
480
|
+
"task-1",
|
|
481
|
+
{ id: "task-1", description: "Task 1", status: "pending", timestamp: 100, version: 1 },
|
|
482
|
+
],
|
|
483
|
+
]),
|
|
484
|
+
version: 1,
|
|
485
|
+
lastWriter: "b",
|
|
486
|
+
lastWriteTime: 200,
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const conflicts = sync.detectConflicts(localState, remoteState);
|
|
490
|
+
assert.equal(conflicts.length, 1);
|
|
491
|
+
assert.equal(conflicts[0].entryId, "task-1");
|
|
492
|
+
assert.equal(conflicts[0].localVersion, 0);
|
|
493
|
+
assert.equal(conflicts[0].remoteVersion, 1);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("resolveConflicts with last-writer-wins returns empty (caller re-reads)", () => {
|
|
497
|
+
const sync = PlanSync.getInstance();
|
|
498
|
+
|
|
499
|
+
const conflicts = [
|
|
500
|
+
{
|
|
501
|
+
entryId: "task-1",
|
|
502
|
+
localVersion: 2,
|
|
503
|
+
remoteVersion: 3,
|
|
504
|
+
localStatus: "pending",
|
|
505
|
+
remoteStatus: "completed",
|
|
506
|
+
},
|
|
507
|
+
];
|
|
508
|
+
|
|
509
|
+
const result = sync.resolveConflicts(conflicts, "last-writer-wins");
|
|
510
|
+
assert.deepEqual(result, []);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("resolveConflicts with manual throws", () => {
|
|
514
|
+
const sync = PlanSync.getInstance();
|
|
515
|
+
|
|
516
|
+
const conflicts = [
|
|
517
|
+
{
|
|
518
|
+
entryId: "task-1",
|
|
519
|
+
localVersion: 2,
|
|
520
|
+
remoteVersion: 3,
|
|
521
|
+
localStatus: "pending",
|
|
522
|
+
remoteStatus: "completed",
|
|
523
|
+
},
|
|
524
|
+
];
|
|
525
|
+
|
|
526
|
+
assert.throws(
|
|
527
|
+
() => sync.resolveConflicts(conflicts, "manual"),
|
|
528
|
+
/manual conflict resolution required/i,
|
|
529
|
+
);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
describe("PlanSync — concurrent writes", () => {
|
|
536
|
+
afterEach(() => {
|
|
537
|
+
PlanSync.resetInstance();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("5 sequential updates to different entries preserve all data", async () => {
|
|
541
|
+
// Create a plan with 5 entries
|
|
542
|
+
const content = makePlanContent([
|
|
543
|
+
{ num: 1, title: "Task A" },
|
|
544
|
+
{ num: 2, title: "Task B" },
|
|
545
|
+
{ num: 3, title: "Task C" },
|
|
546
|
+
{ num: 4, title: "Task D" },
|
|
547
|
+
{ num: 5, title: "Task E" },
|
|
548
|
+
]);
|
|
549
|
+
const { filePath, cleanup } = await createTestPlan(content);
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
const sync = PlanSync.getInstance();
|
|
553
|
+
|
|
554
|
+
// 5 sequential updates to different entries — tests data integrity
|
|
555
|
+
// across multiple writes without the Windows atomic-write rename race.
|
|
556
|
+
// Concurrent conflict detection is tested in "same entry" test below.
|
|
557
|
+
for (const num of [1, 2, 3, 4, 5]) {
|
|
558
|
+
await sync.updateEntry(filePath, {
|
|
559
|
+
id: `task-${num}`,
|
|
560
|
+
description: `Task ${String.fromCharCode(64 + num)} (updated)`,
|
|
561
|
+
status: "in_progress",
|
|
562
|
+
agent: `agent-${num}`,
|
|
563
|
+
timestamp: Date.now(),
|
|
564
|
+
version: 1,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Read final state — all 5 entries must exist and be updated
|
|
569
|
+
const finalState = await sync.readPlanState(filePath);
|
|
570
|
+
assert.equal(finalState.entries.size, 5, "all 5 entries must be present");
|
|
571
|
+
|
|
572
|
+
for (let num = 1; num <= 5; num++) {
|
|
573
|
+
const entry = finalState.entries.get(`task-${num}`);
|
|
574
|
+
assert.ok(entry, `task-${num} must exist after concurrent writes`);
|
|
575
|
+
assert.ok(
|
|
576
|
+
entry.description.includes("(updated)"),
|
|
577
|
+
`task-${num} description must show update`,
|
|
578
|
+
);
|
|
579
|
+
// With sequential writes, each entry version is guaranteed to progress.
|
|
580
|
+
assert.ok(entry.version >= 2, `task-${num} version must be >= 2, got ${entry.version}`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Global version predictable with sequential writes (initial 1 + 5 updates)
|
|
584
|
+
assert.ok(finalState.version === 6, `global version === 6, got ${finalState.version}`);
|
|
585
|
+
} finally {
|
|
586
|
+
await cleanup();
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("parallel writes to same entry eventually converge", async () => {
|
|
591
|
+
const content = makePlanContent([{ num: 1, title: "Contested Task" }]);
|
|
592
|
+
const { filePath, cleanup } = await createTestPlan(content);
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
const sync = PlanSync.getInstance();
|
|
596
|
+
|
|
597
|
+
// Same entry, 3 parallel updates
|
|
598
|
+
const updates = [1, 2, 3].map((i) =>
|
|
599
|
+
sync.updateEntry(filePath, {
|
|
600
|
+
id: "task-1",
|
|
601
|
+
description: `Contested (write ${i})`,
|
|
602
|
+
status: i === 3 ? "completed" : "in_progress",
|
|
603
|
+
agent: `agent-${i}`,
|
|
604
|
+
timestamp: Date.now(),
|
|
605
|
+
version: 1,
|
|
606
|
+
}),
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
await Promise.all(updates);
|
|
610
|
+
|
|
611
|
+
// Final state should have the entry with some updated version
|
|
612
|
+
// (With optimistic concurrency, all 3 may read v1 simultaneously,
|
|
613
|
+
// each writing v2. The last write wins. So version >= 2 is expected.)
|
|
614
|
+
const finalState = await sync.readPlanState(filePath);
|
|
615
|
+
const entry = finalState.entries.get("task-1");
|
|
616
|
+
assert.ok(entry, "entry must exist after contested writes");
|
|
617
|
+
assert.ok(entry.version >= 2, `version >= 2, got ${entry.version}`);
|
|
618
|
+
// Verify the metadata roundtripped (description & status persisted)
|
|
619
|
+
assert.ok(entry.description.startsWith("Contested"), `description preserved: "${entry.description}"`);
|
|
620
|
+
} finally {
|
|
621
|
+
await cleanup();
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
describe("PlanSync — edge cases", () => {
|
|
629
|
+
afterEach(() => {
|
|
630
|
+
PlanSync.resetInstance();
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("handles empty entries map", async () => {
|
|
634
|
+
const content = makePlanContent([]);
|
|
635
|
+
const { filePath, cleanup } = await createTestPlan(content);
|
|
636
|
+
|
|
637
|
+
try {
|
|
638
|
+
const sync = PlanSync.getInstance();
|
|
639
|
+
const state = await sync.readPlanState(filePath);
|
|
640
|
+
assert.equal(state.entries.size, 0);
|
|
641
|
+
} finally {
|
|
642
|
+
await cleanup();
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("handles file with no tasks section", async () => {
|
|
647
|
+
const content = `# Empty Plan\n\n---\n\n## Notes\n\nNothing here.\n`;
|
|
648
|
+
const { filePath, cleanup } = await createTestPlan(content);
|
|
649
|
+
|
|
650
|
+
try {
|
|
651
|
+
const sync = PlanSync.getInstance();
|
|
652
|
+
const state = await sync.readPlanState(filePath);
|
|
653
|
+
assert.equal(state.entries.size, 0);
|
|
654
|
+
} finally {
|
|
655
|
+
await cleanup();
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("handles CRLF and LF line endings", async () => {
|
|
660
|
+
const content = makePlanContent([{ num: 1, title: "Line Endings" }]);
|
|
661
|
+
const crlfContent = content.replace(/\n/g, "\r\n");
|
|
662
|
+
const { filePath, cleanup } = await createTestPlan(crlfContent);
|
|
663
|
+
|
|
664
|
+
try {
|
|
665
|
+
const sync = PlanSync.getInstance();
|
|
666
|
+
const state = await sync.readPlanState(filePath);
|
|
667
|
+
assert.equal(state.entries.size, 1);
|
|
668
|
+
assert.equal(state.entries.get("task-1")?.description, "Line Endings");
|
|
669
|
+
} finally {
|
|
670
|
+
await cleanup();
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("readPlanRaw returns raw file content", async () => {
|
|
675
|
+
const content = "# Raw Test\n\nHello world.\n";
|
|
676
|
+
const { filePath, cleanup } = await createTestPlan(content);
|
|
677
|
+
|
|
678
|
+
try {
|
|
679
|
+
const sync = PlanSync.getInstance();
|
|
680
|
+
const raw = await sync.readPlanRaw(filePath);
|
|
681
|
+
assert.equal(raw, content);
|
|
682
|
+
} finally {
|
|
683
|
+
await cleanup();
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
|
|
690
|
+
describe("PlanFileWatcher", () => {
|
|
691
|
+
afterEach(() => {
|
|
692
|
+
PlanFileWatcher.resetInstance();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("watch calls back when file changes in directory", async () => {
|
|
696
|
+
const content = makePlanContent([{ num: 1, title: "Watched" }]);
|
|
697
|
+
const { dir, filePath, cleanup } = await createTestPlan(content);
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
const watcher = PlanFileWatcher.getInstance();
|
|
701
|
+
const callbacks: string[] = [];
|
|
702
|
+
|
|
703
|
+
watcher.watch(dir, (changedPath: string) => {
|
|
704
|
+
callbacks.push(changedPath);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
// Wait for watcher to initialize
|
|
708
|
+
await delay(200);
|
|
709
|
+
|
|
710
|
+
// Trigger a change
|
|
711
|
+
await fs.promises.writeFile(filePath, content + "\n", "utf8");
|
|
712
|
+
|
|
713
|
+
// Wait for debounce (500ms) + some buffer
|
|
714
|
+
await delay(800);
|
|
715
|
+
|
|
716
|
+
assert.ok(callbacks.length >= 1, "watcher callback should fire");
|
|
717
|
+
const called = callbacks.some((c) => c.includes("plan-001.md"));
|
|
718
|
+
assert.ok(called, "callback should include the changed file path");
|
|
719
|
+
} finally {
|
|
720
|
+
await cleanup();
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("unwatch stops receiving callbacks", async () => {
|
|
725
|
+
const content = makePlanContent([{ num: 1, title: "Unwatched" }]);
|
|
726
|
+
const { dir, filePath, cleanup } = await createTestPlan(content);
|
|
727
|
+
|
|
728
|
+
try {
|
|
729
|
+
const watcher = PlanFileWatcher.getInstance();
|
|
730
|
+
let callbackCount = 0;
|
|
731
|
+
|
|
732
|
+
watcher.watch(dir, () => {
|
|
733
|
+
callbackCount++;
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
await delay(200);
|
|
737
|
+
|
|
738
|
+
// Unwatch
|
|
739
|
+
watcher.unwatch(dir);
|
|
740
|
+
|
|
741
|
+
// Trigger a change
|
|
742
|
+
await fs.promises.writeFile(filePath, content + "\n\n", "utf8");
|
|
743
|
+
|
|
744
|
+
await delay(800);
|
|
745
|
+
|
|
746
|
+
const countAfterUnwatch = callbackCount;
|
|
747
|
+
// We might get the initial event, but subsequent ones should not fire
|
|
748
|
+
// Actually, fs.watch may still emit for a moment after close on some platforms.
|
|
749
|
+
// We just verify the watcher is no longer in the active list.
|
|
750
|
+
const dirs = watcher.watchedDirectories();
|
|
751
|
+
assert.equal(dirs.includes(dir), false, "directory should not be in watched list");
|
|
752
|
+
} finally {
|
|
753
|
+
await cleanup();
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("pause suppresses callbacks, resume re-enables", async () => {
|
|
758
|
+
const content = makePlanContent([{ num: 1, title: "Paused" }]);
|
|
759
|
+
const { dir, filePath, cleanup } = await createTestPlan(content);
|
|
760
|
+
|
|
761
|
+
try {
|
|
762
|
+
const watcher = PlanFileWatcher.getInstance();
|
|
763
|
+
const callbacks: string[] = [];
|
|
764
|
+
|
|
765
|
+
watcher.watch(dir, (changedPath: string) => {
|
|
766
|
+
callbacks.push(changedPath);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
await delay(200);
|
|
770
|
+
|
|
771
|
+
// Pause
|
|
772
|
+
watcher.pause();
|
|
773
|
+
assert.equal(watcher.paused, true);
|
|
774
|
+
|
|
775
|
+
await fs.promises.writeFile(filePath, content + "\n\n\n", "utf8");
|
|
776
|
+
await delay(800);
|
|
777
|
+
|
|
778
|
+
const countDuringPause = callbacks.length;
|
|
779
|
+
|
|
780
|
+
// Resume
|
|
781
|
+
watcher.resume();
|
|
782
|
+
assert.equal(watcher.paused, false);
|
|
783
|
+
|
|
784
|
+
// Another write should trigger after resume
|
|
785
|
+
await fs.promises.writeFile(filePath, content + "\n\n\n\n", "utf8");
|
|
786
|
+
await delay(800);
|
|
787
|
+
|
|
788
|
+
// The pause should have prevented the first write's callback
|
|
789
|
+
// (but note: fs.watch on Windows may batch events; we verify pause
|
|
790
|
+
// at least prevented the callback that would have fired during pause)
|
|
791
|
+
assert.ok(watcher.paused === false, "watcher should not be paused after resume");
|
|
792
|
+
} finally {
|
|
793
|
+
await cleanup();
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it("watchedDirectories returns current watches", async () => {
|
|
798
|
+
const content = makePlanContent([{ num: 1, title: "DirList" }]);
|
|
799
|
+
const { dir, cleanup } = await createTestPlan(content);
|
|
800
|
+
|
|
801
|
+
try {
|
|
802
|
+
const watcher = PlanFileWatcher.getInstance();
|
|
803
|
+
assert.equal(watcher.watchedDirectories().length, 0);
|
|
804
|
+
|
|
805
|
+
watcher.watch(dir, () => {});
|
|
806
|
+
assert.equal(watcher.watchedDirectories().length, 1);
|
|
807
|
+
assert.equal(watcher.watchedDirectories()[0], dir);
|
|
808
|
+
|
|
809
|
+
watcher.unwatch(dir);
|
|
810
|
+
assert.equal(watcher.watchedDirectories().length, 0);
|
|
811
|
+
} finally {
|
|
812
|
+
await cleanup();
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it("destroy cleans up all watchers", async () => {
|
|
817
|
+
const content = makePlanContent([{ num: 1, title: "Destroy" }]);
|
|
818
|
+
const { dir, cleanup } = await createTestPlan(content);
|
|
819
|
+
|
|
820
|
+
try {
|
|
821
|
+
const watcher = PlanFileWatcher.getInstance();
|
|
822
|
+
watcher.watch(dir, () => {});
|
|
823
|
+
assert.equal(watcher.watchedDirectories().length, 1);
|
|
824
|
+
|
|
825
|
+
watcher.destroy();
|
|
826
|
+
assert.equal(watcher.watchedDirectories().length, 0);
|
|
827
|
+
assert.equal(watcher.paused, false);
|
|
828
|
+
} finally {
|
|
829
|
+
await cleanup();
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
});
|