openhermes 4.3.0 → 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 +10 -1
- package/README.md +54 -42
- package/bootstrap.ts +396 -142
- package/harness/agents/oh-browser.md +97 -0
- package/harness/agents/oh-builder.md +78 -0
- package/harness/agents/oh-facade.md +75 -0
- package/harness/agents/oh-fusion.md +45 -0
- package/harness/agents/oh-gauntlet.md +71 -0
- package/harness/agents/oh-grill.md +71 -0
- package/harness/agents/oh-investigate.md +60 -0
- package/harness/agents/oh-manifest.md +95 -0
- package/harness/agents/oh-plan-review.md +40 -0
- package/harness/agents/oh-planner.md +50 -0
- package/harness/agents/oh-refactor.md +37 -0
- package/harness/agents/oh-retro.md +46 -0
- package/harness/agents/oh-review.md +85 -0
- package/harness/agents/oh-security.md +83 -0
- package/harness/agents/oh-ship.md +76 -0
- package/harness/agents/oh-skill-craft.md +38 -0
- package/harness/agents/openhermes.md +28 -73
- package/harness/codex/AUTOPILOT.md +235 -87
- package/harness/codex/CHARTER.md +80 -0
- package/harness/instructions/SHELL.md +76 -0
- 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-ascii/DEEP.md +292 -0
- package/harness/skills/oh-ascii/SKILL.md +31 -0
- package/harness/skills/oh-ascii/scripts/check_ascii_alignment.py +596 -0
- package/harness/skills/oh-browser/DEEP.md +54 -0
- package/harness/skills/oh-browser/SKILL.md +30 -0
- package/harness/skills/oh-builder/DEEP.md +63 -0
- package/harness/skills/oh-builder/SKILL.md +12 -90
- package/harness/skills/oh-expert/DEEP.md +85 -0
- package/harness/skills/oh-expert/SKILL.md +13 -106
- package/harness/skills/oh-facade/DEEP.md +182 -0
- package/harness/skills/oh-facade/SKILL.md +15 -279
- package/harness/skills/oh-freeze/DEEP.md +18 -0
- package/harness/skills/oh-freeze/SKILL.md +10 -19
- package/harness/skills/oh-full-output/DEEP.md +25 -0
- package/harness/skills/oh-full-output/SKILL.md +12 -65
- package/harness/skills/oh-fusion/DEEP.md +120 -0
- package/harness/skills/oh-fusion/SKILL.md +17 -295
- package/harness/skills/oh-gauntlet/DEEP.md +77 -0
- package/harness/skills/oh-gauntlet/SKILL.md +13 -105
- package/harness/skills/oh-grill/DEEP.md +51 -0
- package/harness/skills/oh-grill/SKILL.md +12 -63
- package/harness/skills/oh-guard/DEEP.md +19 -0
- package/harness/skills/oh-guard/SKILL.md +10 -24
- package/harness/skills/oh-handoff/DEEP.md +48 -0
- package/harness/skills/oh-handoff/SKILL.md +13 -23
- package/harness/skills/oh-health/DEEP.md +74 -0
- package/harness/skills/oh-health/SKILL.md +13 -76
- package/harness/skills/oh-init/DEEP.md +85 -0
- package/harness/skills/oh-init/SKILL.md +13 -127
- package/harness/skills/oh-investigate/DEEP.md +171 -0
- package/harness/skills/oh-investigate/SKILL.md +13 -66
- package/harness/skills/oh-issue/DEEP.md +21 -0
- package/harness/skills/oh-issue/SKILL.md +11 -27
- package/harness/skills/oh-manifest/DEEP.md +92 -0
- package/harness/skills/oh-manifest/SKILL.md +12 -109
- package/harness/skills/oh-plan-review/DEEP.md +90 -0
- package/harness/skills/oh-plan-review/SKILL.md +13 -115
- package/harness/skills/oh-planner/DEEP.md +172 -0
- package/harness/skills/oh-planner/SKILL.md +12 -149
- package/harness/skills/oh-prd/DEEP.md +45 -0
- package/harness/skills/oh-prd/SKILL.md +10 -26
- package/harness/skills/oh-refactor/DEEP.md +122 -0
- package/harness/skills/oh-refactor/SKILL.md +17 -410
- package/harness/skills/oh-retro/DEEP.md +26 -0
- package/harness/skills/oh-retro/SKILL.md +12 -24
- package/harness/skills/oh-review/DEEP.md +87 -0
- package/harness/skills/oh-review/SKILL.md +11 -97
- package/harness/skills/oh-security/DEEP.md +83 -0
- package/harness/skills/oh-security/SKILL.md +14 -96
- package/harness/skills/oh-ship/DEEP.md +141 -0
- package/harness/skills/oh-ship/SKILL.md +14 -32
- package/harness/skills/oh-skill-craft/DEEP.md +369 -0
- package/harness/skills/oh-skill-craft/SKILL.md +13 -177
- package/harness/skills/oh-skills-link/DEEP.md +16 -0
- package/harness/skills/oh-skills-link/SKILL.md +10 -20
- package/harness/skills/oh-skills-list/DEEP.md +20 -0
- package/harness/skills/oh-skills-list/SKILL.md +9 -22
- package/harness/skills/oh-triage/DEEP.md +23 -0
- package/harness/skills/oh-triage/SKILL.md +8 -24
- package/harness/skills/oh-worktree/DEEP.md +169 -0
- package/harness/skills/oh-worktree/SKILL.md +32 -0
- package/lib/harness-resolver.ts +8 -10
- package/package.json +7 -5
- package/tsconfig.json +1 -1
- package/harness/codex/CONSTITUTION.md +0 -73
- package/harness/codex/ROUTING.md +0 -92
- package/harness/commands/oh-doctor.md +0 -26
- package/harness/commands/oh-log.md +0 -18
- package/harness/instructions/RUNTIME.md +0 -30
- package/harness/skills/oh-caveman/SKILL.md +0 -42
- package/harness/skills/oh-learn/SKILL.md +0 -101
- package/lib/logger.ts +0 -75
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// PlanStore — wraps plan file read/write with memory, findings & decisions
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import type {
|
|
9
|
+
MemoryEntry,
|
|
10
|
+
Finding,
|
|
11
|
+
Decision,
|
|
12
|
+
} from "./interfaces.ts";
|
|
13
|
+
import { MemoryLevel } from "./interfaces.ts";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Simple per-path mutex — serializes concurrent read-modify-write cycles
|
|
17
|
+
// for the same plan file. Keyed by planPath so writes to different files
|
|
18
|
+
// proceed in parallel.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
class PathMutex {
|
|
22
|
+
private locked = false;
|
|
23
|
+
private queue: (() => void)[] = [];
|
|
24
|
+
|
|
25
|
+
acquire(): Promise<void> {
|
|
26
|
+
if (!this.locked) {
|
|
27
|
+
this.locked = true;
|
|
28
|
+
return Promise.resolve();
|
|
29
|
+
}
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
this.queue.push(() => {
|
|
32
|
+
this.locked = true;
|
|
33
|
+
resolve();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
release(): void {
|
|
39
|
+
if (this.queue.length > 0) {
|
|
40
|
+
const next = this.queue.shift()!;
|
|
41
|
+
next();
|
|
42
|
+
} else {
|
|
43
|
+
this.locked = false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const planLocks = new Map<string, PathMutex>();
|
|
49
|
+
|
|
50
|
+
function getPlanLock(planPath: string): PathMutex {
|
|
51
|
+
let lock = planLocks.get(planPath);
|
|
52
|
+
if (!lock) {
|
|
53
|
+
lock = new PathMutex();
|
|
54
|
+
planLocks.set(planPath, lock);
|
|
55
|
+
}
|
|
56
|
+
return lock;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Public types
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
export interface PlanData {
|
|
64
|
+
tasks: MemoryPlanEntry[];
|
|
65
|
+
memory: MemoryEntry[];
|
|
66
|
+
findings: Finding[];
|
|
67
|
+
decisions: Decision[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface MemoryPlanEntry {
|
|
71
|
+
id: string;
|
|
72
|
+
description: string;
|
|
73
|
+
status: "pending" | "in_progress" | "completed" | "blocked";
|
|
74
|
+
dependsOn: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Store
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
export class PlanStore {
|
|
82
|
+
/**
|
|
83
|
+
* Read a plan file and parse its structured sections.
|
|
84
|
+
* Returns default values if the file does not exist or cannot be parsed.
|
|
85
|
+
*/
|
|
86
|
+
static async readPlan(planPath: string): Promise<PlanData> {
|
|
87
|
+
if (!fs.existsSync(planPath)) {
|
|
88
|
+
return { tasks: [], memory: [], findings: [], decisions: [] };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const source = await fs.promises.readFile(planPath, "utf8");
|
|
93
|
+
return parsePlanDocument(source);
|
|
94
|
+
} catch {
|
|
95
|
+
return { tasks: [], memory: [], findings: [], decisions: [] };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Write plan data back into a markdown plan file.
|
|
101
|
+
* Preserves the original header / frontmatter and appends structured sections.
|
|
102
|
+
*/
|
|
103
|
+
static async writePlan(planPath: string, data: PlanData): Promise<void> {
|
|
104
|
+
const sections: string[] = [];
|
|
105
|
+
|
|
106
|
+
// Preserve existing header if file exists
|
|
107
|
+
if (fs.existsSync(planPath)) {
|
|
108
|
+
const existing = await fs.promises.readFile(planPath, "utf8");
|
|
109
|
+
const header = extractHeader(existing);
|
|
110
|
+
if (header) sections.push(header);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Tasks section
|
|
114
|
+
sections.push("", "## Tasks", "");
|
|
115
|
+
if (data.tasks.length === 0) {
|
|
116
|
+
sections.push("- [ ] (no tasks)");
|
|
117
|
+
} else {
|
|
118
|
+
for (const task of data.tasks) {
|
|
119
|
+
const checkbox = task.status === "completed" ? "x" : " ";
|
|
120
|
+
const dep = task.dependsOn.length > 0 ? ` [depends: ${task.dependsOn.join(", ")}]` : "";
|
|
121
|
+
sections.push(`- [${checkbox}] ${task.description}${dep}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Memory section
|
|
126
|
+
if (data.memory.length > 0) {
|
|
127
|
+
sections.push("", "## Memory", "");
|
|
128
|
+
for (const entry of data.memory) {
|
|
129
|
+
const meta = entry.metadata
|
|
130
|
+
? ` ${JSON.stringify(entry.metadata)}`
|
|
131
|
+
: "";
|
|
132
|
+
sections.push(
|
|
133
|
+
`- [${entry.level}] (${entry.importance.toFixed(2)}) ${entry.content}${meta}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Findings section
|
|
139
|
+
if (data.findings.length > 0) {
|
|
140
|
+
sections.push("", "## Findings", "");
|
|
141
|
+
for (const finding of data.findings) {
|
|
142
|
+
sections.push(
|
|
143
|
+
`- [${finding.severity}] ${finding.description} _(session: ${finding.sessionId})_`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Decisions section
|
|
149
|
+
if (data.decisions.length > 0) {
|
|
150
|
+
sections.push("", "## Decisions", "");
|
|
151
|
+
for (const decision of data.decisions) {
|
|
152
|
+
sections.push(
|
|
153
|
+
`- **${decision.description}** — ${decision.rationale} _(session: ${decision.sessionId})_`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
sections.push(""); // trailing newline
|
|
159
|
+
|
|
160
|
+
// -----------------------------------------------------------------------
|
|
161
|
+
// Atomic write: write to temp file in same directory, then rename.
|
|
162
|
+
// Avoids partial/corrupt files on crash mid-write.
|
|
163
|
+
// Pattern adapted from plan-sync.ts atomicWrite().
|
|
164
|
+
// -----------------------------------------------------------------------
|
|
165
|
+
const dir = path.dirname(planPath);
|
|
166
|
+
const base = path.basename(planPath);
|
|
167
|
+
const tmpPath = path.join(dir, `.${base}.${process.pid}_${Date.now()}.tmp`);
|
|
168
|
+
await fs.promises.writeFile(tmpPath, sections.join("\n"), "utf8");
|
|
169
|
+
try {
|
|
170
|
+
await fs.promises.rename(tmpPath, planPath);
|
|
171
|
+
} catch {
|
|
172
|
+
// EPERM on Windows (cross-device or locking): write content directly
|
|
173
|
+
// to target. Content is already in memory as `sections.join("\n")`.
|
|
174
|
+
await fs.promises.writeFile(planPath, sections.join("\n"), "utf8");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Add a finding to the plan file at the given path.
|
|
180
|
+
*
|
|
181
|
+
* Uses a per-path mutex to prevent lost-update races when called
|
|
182
|
+
* concurrently from memory-sync-hook (or any other caller).
|
|
183
|
+
*/
|
|
184
|
+
static async addFinding(
|
|
185
|
+
planPath: string,
|
|
186
|
+
sessionId: string,
|
|
187
|
+
finding: Omit<Finding, "id" | "sessionId" | "timestamp">,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
const lock = getPlanLock(planPath);
|
|
190
|
+
await lock.acquire();
|
|
191
|
+
try {
|
|
192
|
+
const data = await PlanStore.readPlan(planPath);
|
|
193
|
+
const newFinding: Finding = {
|
|
194
|
+
id: randomUUID(),
|
|
195
|
+
sessionId,
|
|
196
|
+
...finding,
|
|
197
|
+
timestamp: Date.now(),
|
|
198
|
+
};
|
|
199
|
+
data.findings.push(newFinding);
|
|
200
|
+
await PlanStore.writePlan(planPath, data);
|
|
201
|
+
} finally {
|
|
202
|
+
lock.release();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Add a decision to the plan file at the given path.
|
|
208
|
+
*
|
|
209
|
+
* Uses a per-path mutex to prevent lost-update races when called
|
|
210
|
+
* concurrently from memory-sync-hook (or any other caller).
|
|
211
|
+
*/
|
|
212
|
+
static async addDecision(
|
|
213
|
+
planPath: string,
|
|
214
|
+
sessionId: string,
|
|
215
|
+
decision: Omit<Decision, "id" | "sessionId" | "timestamp">,
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
const lock = getPlanLock(planPath);
|
|
218
|
+
await lock.acquire();
|
|
219
|
+
try {
|
|
220
|
+
const data = await PlanStore.readPlan(planPath);
|
|
221
|
+
const newDecision: Decision = {
|
|
222
|
+
id: randomUUID(),
|
|
223
|
+
sessionId,
|
|
224
|
+
...decision,
|
|
225
|
+
timestamp: Date.now(),
|
|
226
|
+
};
|
|
227
|
+
data.decisions.push(newDecision);
|
|
228
|
+
await PlanStore.writePlan(planPath, data);
|
|
229
|
+
} finally {
|
|
230
|
+
lock.release();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Merge parent context entries for child sessions.
|
|
236
|
+
* Returns all entries from both parent and current session context.
|
|
237
|
+
*/
|
|
238
|
+
static async getMerged(
|
|
239
|
+
sessionId: string,
|
|
240
|
+
parentSessionId?: string,
|
|
241
|
+
): Promise<MemoryEntry[]> {
|
|
242
|
+
const all: MemoryEntry[] = [];
|
|
243
|
+
|
|
244
|
+
// For now, this is a placeholder that returns empty — real merging
|
|
245
|
+
// requires the caller to provide plan paths. This stub hooks into
|
|
246
|
+
// the intended architecture without dictating I/O strategy.
|
|
247
|
+
if (parentSessionId) {
|
|
248
|
+
// In a real implementation, we would look up the parent session's
|
|
249
|
+
// plan file and merge its memory entries with the child's.
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return all;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Internal parsing helpers
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Parse a plan document string into structured PlanData.
|
|
262
|
+
*/
|
|
263
|
+
function parsePlanDocument(source: string): PlanData {
|
|
264
|
+
const tasks: MemoryPlanEntry[] = [];
|
|
265
|
+
const memory: MemoryEntry[] = [];
|
|
266
|
+
const findings: Finding[] = [];
|
|
267
|
+
const decisions: Decision[] = [];
|
|
268
|
+
|
|
269
|
+
const lines = source.split(/\r?\n/);
|
|
270
|
+
let section: string | null = null;
|
|
271
|
+
|
|
272
|
+
for (const raw of lines) {
|
|
273
|
+
const line = raw.trim();
|
|
274
|
+
|
|
275
|
+
// Section detection
|
|
276
|
+
const sectionMatch = line.match(/^##\s+(.+)$/);
|
|
277
|
+
if (sectionMatch) {
|
|
278
|
+
section = sectionMatch[1].toLowerCase();
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!section || !line) continue;
|
|
283
|
+
|
|
284
|
+
switch (section) {
|
|
285
|
+
case "tasks": {
|
|
286
|
+
const taskMatch = line.match(/^-\s+\[([ x])\]\s+(.+)$/);
|
|
287
|
+
if (taskMatch) {
|
|
288
|
+
const depMatch = taskMatch[2].match(/^(.+?)\s+\[depends:\s+(.+?)\]$/);
|
|
289
|
+
tasks.push({
|
|
290
|
+
id: randomUUID(),
|
|
291
|
+
description: depMatch ? depMatch[1].trim() : taskMatch[2].trim(),
|
|
292
|
+
status: taskMatch[1] === "x" ? "completed" : "pending",
|
|
293
|
+
dependsOn: depMatch ? depMatch[2].split(/,\s*/) : [],
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
case "memory": {
|
|
300
|
+
const memMatch = line.match(
|
|
301
|
+
/^-\s+\[(\w+)\]\s+\(([\d.]+)\)\s+(.+?)(?:\s+(\{.*\}))?$/,
|
|
302
|
+
);
|
|
303
|
+
if (memMatch) {
|
|
304
|
+
let metadata: Record<string, string> | undefined;
|
|
305
|
+
try {
|
|
306
|
+
if (memMatch[4]) metadata = JSON.parse(memMatch[4]);
|
|
307
|
+
} catch {
|
|
308
|
+
// ignore malformed metadata
|
|
309
|
+
}
|
|
310
|
+
memory.push({
|
|
311
|
+
id: randomUUID(),
|
|
312
|
+
level: memMatch[1] as MemoryLevel,
|
|
313
|
+
importance: parseFloat(memMatch[2]),
|
|
314
|
+
content: memMatch[3].trim(),
|
|
315
|
+
timestamp: Date.now(),
|
|
316
|
+
metadata,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
case "findings": {
|
|
323
|
+
const findingMatch = line.match(
|
|
324
|
+
/^-\s+\[(\w+)\]\s+(.+?)\s+_\(session:\s+(.+?)\)_\s*$/,
|
|
325
|
+
);
|
|
326
|
+
if (findingMatch) {
|
|
327
|
+
findings.push({
|
|
328
|
+
id: randomUUID(),
|
|
329
|
+
severity: findingMatch[1] as Finding["severity"],
|
|
330
|
+
description: findingMatch[2].trim(),
|
|
331
|
+
sessionId: findingMatch[3].trim(),
|
|
332
|
+
timestamp: Date.now(),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
case "decisions": {
|
|
339
|
+
const decMatch = line.match(
|
|
340
|
+
/^-\s+\*\*(.+?)\*\*\s*[—–-]+\s*(.+?)\s+_\(session:\s+(.+?)\)_\s*$/,
|
|
341
|
+
);
|
|
342
|
+
if (decMatch) {
|
|
343
|
+
decisions.push({
|
|
344
|
+
id: randomUUID(),
|
|
345
|
+
description: decMatch[1].trim(),
|
|
346
|
+
rationale: decMatch[2].trim(),
|
|
347
|
+
sessionId: decMatch[3].trim(),
|
|
348
|
+
timestamp: Date.now(),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { tasks, memory, findings, decisions };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Extract the header portion of a plan file (everything before the first ##).
|
|
361
|
+
*/
|
|
362
|
+
function extractHeader(source: string): string | null {
|
|
363
|
+
const idx = source.search(/^## /m);
|
|
364
|
+
if (idx < 0) return source.trim();
|
|
365
|
+
return source.slice(0, idx).trim();
|
|
366
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// RecoveryHandler — singleton that classifies errors against patterns,
|
|
2
|
+
// applies recovery actions, and tracks stats.
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
ErrorCategory,
|
|
6
|
+
ErrorContext,
|
|
7
|
+
RecoveryAction,
|
|
8
|
+
RecoveryActionType,
|
|
9
|
+
RecoveryRecord,
|
|
10
|
+
RecoveryStats,
|
|
11
|
+
} from "./interfaces.ts";
|
|
12
|
+
import { PATTERNS, escalateAction } from "./patterns.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Represents the number of times we have *attempted recovery* for a
|
|
16
|
+
* given (sessionId, category) pair. Separate from the sub-agent's own
|
|
17
|
+
* attempt counter which is passed in ErrorContext.attempt.
|
|
18
|
+
*/
|
|
19
|
+
interface CategoryAttemptState {
|
|
20
|
+
count: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class RecoveryHandler {
|
|
24
|
+
private static instance: RecoveryHandler;
|
|
25
|
+
|
|
26
|
+
/** All recovery records keyed by sessionId then timestamp. */
|
|
27
|
+
private history: RecoveryRecord[] = [];
|
|
28
|
+
|
|
29
|
+
/** Track attempts per (sessionId + category) to enforce maxAttempts. */
|
|
30
|
+
private attemptTracker = new Map<string, CategoryAttemptState>();
|
|
31
|
+
|
|
32
|
+
/** Total number of recoveries that succeeded (fn completed without throwing). */
|
|
33
|
+
private successCount = 0;
|
|
34
|
+
private failureCount = 0;
|
|
35
|
+
|
|
36
|
+
private constructor() {}
|
|
37
|
+
|
|
38
|
+
/** Get the singleton instance. */
|
|
39
|
+
static getInstance(): RecoveryHandler {
|
|
40
|
+
if (!RecoveryHandler.instance) {
|
|
41
|
+
RecoveryHandler.instance = new RecoveryHandler();
|
|
42
|
+
}
|
|
43
|
+
return RecoveryHandler.instance;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Classify an error and return the appropriate recovery action.
|
|
48
|
+
* Returns the first matching pattern's action, or escalates if no match.
|
|
49
|
+
*/
|
|
50
|
+
handleError(context: ErrorContext): RecoveryAction {
|
|
51
|
+
const message = context.error.message ?? String(context.error);
|
|
52
|
+
|
|
53
|
+
for (const entry of PATTERNS) {
|
|
54
|
+
if (entry.pattern.test(message)) {
|
|
55
|
+
const action = entry.getAction(context);
|
|
56
|
+
this.record(context, action);
|
|
57
|
+
return action;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// No pattern matched — escalate
|
|
62
|
+
const action = escalateAction(context);
|
|
63
|
+
this.record(context, action);
|
|
64
|
+
return action;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Wraps an async function with auto-recovery.
|
|
69
|
+
*
|
|
70
|
+
* On each throw:
|
|
71
|
+
* 1. Classify the error via handleError()
|
|
72
|
+
* 2. If action is "abort" | "escalate" | "skip" — rethrow immediately
|
|
73
|
+
* 3. If action is "compact" | "retry" — check maxAttempts, delay, retry
|
|
74
|
+
*
|
|
75
|
+
* If the function succeeds, increments successCount.
|
|
76
|
+
*/
|
|
77
|
+
async withRecovery<T>(
|
|
78
|
+
sessionId: string,
|
|
79
|
+
fn: () => Promise<T>,
|
|
80
|
+
options?: { maxAttempts?: number },
|
|
81
|
+
): Promise<T> {
|
|
82
|
+
const globalMax = options?.maxAttempts ?? 5;
|
|
83
|
+
let attempt = 0;
|
|
84
|
+
|
|
85
|
+
while (attempt < globalMax) {
|
|
86
|
+
try {
|
|
87
|
+
const result = await fn();
|
|
88
|
+
this.successCount++;
|
|
89
|
+
return result;
|
|
90
|
+
} catch (err: unknown) {
|
|
91
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
92
|
+
const context: ErrorContext = {
|
|
93
|
+
sessionId,
|
|
94
|
+
error,
|
|
95
|
+
attempt,
|
|
96
|
+
timestamp: Date.now(),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const action = this.handleError(context);
|
|
100
|
+
|
|
101
|
+
// Non-recoverable actions — rethrow immediately
|
|
102
|
+
if (action.type === "abort" || action.type === "escalate" || action.type === "skip") {
|
|
103
|
+
this.failureCount++;
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Enforce category-specific maxAttempts
|
|
108
|
+
if (action.maxAttempts !== undefined) {
|
|
109
|
+
const category = this.findCategory(action.reason);
|
|
110
|
+
const key = `${sessionId}::${category}`;
|
|
111
|
+
const tracker = this.attemptTracker.get(key) ?? { count: 0 };
|
|
112
|
+
tracker.count++;
|
|
113
|
+
this.attemptTracker.set(key, tracker);
|
|
114
|
+
|
|
115
|
+
if (tracker.count >= action.maxAttempts) {
|
|
116
|
+
this.failureCount++;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Apply delay if specified
|
|
122
|
+
if (action.delay && action.delay > 0) {
|
|
123
|
+
await this.sleep(action.delay);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
attempt++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.failureCount++;
|
|
131
|
+
// Exhausted global maxAttempts
|
|
132
|
+
throw new Error(
|
|
133
|
+
`[RecoveryHandler] Exhausted ${globalMax} attempts for session "${sessionId}"`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Convenience: sleep for ms. */
|
|
138
|
+
private sleep(ms: number): Promise<void> {
|
|
139
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Derive a category from an action's reason string.
|
|
144
|
+
* Falls back to "timeout" if no pattern matches.
|
|
145
|
+
*/
|
|
146
|
+
private findCategory(reason: string): ErrorCategory {
|
|
147
|
+
for (const entry of PATTERNS) {
|
|
148
|
+
if (entry.pattern.test(reason)) {
|
|
149
|
+
return entry.category;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Attempt to extract from the reason string heuristically
|
|
153
|
+
const lower = reason.toLowerCase();
|
|
154
|
+
if (lower.includes("rate")) return "rate_limit";
|
|
155
|
+
if (lower.includes("context") || lower.includes("token")) return "context_overflow";
|
|
156
|
+
if (lower.includes("network") || lower.includes("econnrefused")) return "network";
|
|
157
|
+
if (lower.includes("session")) return "session";
|
|
158
|
+
if (lower.includes("tool")) return "tool_error";
|
|
159
|
+
if (lower.includes("parse") || lower.includes("json")) return "parse_error";
|
|
160
|
+
if (lower.includes("gibberish")) return "gibberish";
|
|
161
|
+
if (lower.includes("lsp") || lower.includes("tsc") || lower.includes("eslint")) {
|
|
162
|
+
return "lsp_diagnostic";
|
|
163
|
+
}
|
|
164
|
+
if (lower.includes("timeout") || lower.includes("timed")) return "timeout";
|
|
165
|
+
return "timeout";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Accumulated statistics. */
|
|
169
|
+
getStats(): RecoveryStats {
|
|
170
|
+
const byCategory: Record<ErrorCategory, number> = {
|
|
171
|
+
rate_limit: 0,
|
|
172
|
+
context_overflow: 0,
|
|
173
|
+
network: 0,
|
|
174
|
+
session: 0,
|
|
175
|
+
tool_error: 0,
|
|
176
|
+
parse_error: 0,
|
|
177
|
+
gibberish: 0,
|
|
178
|
+
lsp_diagnostic: 0,
|
|
179
|
+
timeout: 0,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const byAction: Record<RecoveryActionType, number> = {
|
|
183
|
+
retry: 0,
|
|
184
|
+
abort: 0,
|
|
185
|
+
skip: 0,
|
|
186
|
+
escalate: 0,
|
|
187
|
+
compact: 0,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
for (const record of this.history) {
|
|
191
|
+
const cat = this.findCategory(record.action.reason);
|
|
192
|
+
if (byCategory[cat] !== undefined) byCategory[cat]++;
|
|
193
|
+
if (byAction[record.action.type] !== undefined) byAction[record.action.type]++;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const totalRecoveries = this.history.length;
|
|
197
|
+
const totalAttempts = this.successCount + this.failureCount;
|
|
198
|
+
const successRate = totalAttempts > 0 ? this.successCount / totalAttempts : 0;
|
|
199
|
+
|
|
200
|
+
return { totalRecoveries, byCategory, byAction, successRate };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Recent recovery records, most recent first. */
|
|
204
|
+
getHistory(limit?: number): RecoveryRecord[] {
|
|
205
|
+
const sorted = [...this.history].sort((a, b) => b.timestamp - a.timestamp);
|
|
206
|
+
return limit ? sorted.slice(0, limit) : sorted;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Clear all records and attempt state for a given session. */
|
|
210
|
+
clearSession(sessionId: string): void {
|
|
211
|
+
this.history = this.history.filter((r) => r.context.sessionId !== sessionId);
|
|
212
|
+
for (const key of this.attemptTracker.keys()) {
|
|
213
|
+
if (key.startsWith(`${sessionId}::`)) {
|
|
214
|
+
this.attemptTracker.delete(key);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Reset all state (useful in tests). */
|
|
220
|
+
reset(): void {
|
|
221
|
+
this.history = [];
|
|
222
|
+
this.attemptTracker.clear();
|
|
223
|
+
this.successCount = 0;
|
|
224
|
+
this.failureCount = 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── private helpers ──
|
|
228
|
+
|
|
229
|
+
private record(context: ErrorContext, action: RecoveryAction): void {
|
|
230
|
+
// Update attempt tracker using the derived category from the action's reason
|
|
231
|
+
const category = this.findCategory(action.reason);
|
|
232
|
+
const key = `${context.sessionId}::${category}`;
|
|
233
|
+
if (!this.attemptTracker.has(key)) {
|
|
234
|
+
this.attemptTracker.set(key, { count: 0 });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
this.history.push({
|
|
238
|
+
context,
|
|
239
|
+
action,
|
|
240
|
+
timestamp: Date.now(),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Auto-Recovery module — barrel export.
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
ErrorCategory,
|
|
5
|
+
RecoveryActionType,
|
|
6
|
+
RecoveryAction,
|
|
7
|
+
ErrorContext,
|
|
8
|
+
RecoveryRecord,
|
|
9
|
+
RecoveryStats,
|
|
10
|
+
} from "./interfaces.ts";
|
|
11
|
+
|
|
12
|
+
export { RecoveryHandler } from "./handler.ts";
|
|
13
|
+
export { PATTERNS, escalateAction } from "./patterns.ts";
|
|
14
|
+
export type { ErrorPattern } from "./patterns.ts";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Auto-Recovery type definitions for sub-agent error handling.
|
|
2
|
+
|
|
3
|
+
export type ErrorCategory =
|
|
4
|
+
| "rate_limit"
|
|
5
|
+
| "context_overflow"
|
|
6
|
+
| "network"
|
|
7
|
+
| "session"
|
|
8
|
+
| "tool_error"
|
|
9
|
+
| "parse_error"
|
|
10
|
+
| "gibberish"
|
|
11
|
+
| "lsp_diagnostic"
|
|
12
|
+
| "timeout";
|
|
13
|
+
|
|
14
|
+
export type RecoveryActionType =
|
|
15
|
+
| "retry"
|
|
16
|
+
| "abort"
|
|
17
|
+
| "skip"
|
|
18
|
+
| "escalate"
|
|
19
|
+
| "compact";
|
|
20
|
+
|
|
21
|
+
export interface RecoveryAction {
|
|
22
|
+
type: RecoveryActionType;
|
|
23
|
+
delay?: number; // ms delay before retry
|
|
24
|
+
maxAttempts?: number; // max retry attempts
|
|
25
|
+
reason: string;
|
|
26
|
+
modifyPrompt?: string; // instruction to prepend to retry prompt
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ErrorContext {
|
|
30
|
+
sessionId: string;
|
|
31
|
+
error: Error;
|
|
32
|
+
attempt: number;
|
|
33
|
+
timestamp: number;
|
|
34
|
+
agent?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RecoveryRecord {
|
|
38
|
+
context: ErrorContext;
|
|
39
|
+
action: RecoveryAction;
|
|
40
|
+
timestamp: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RecoveryStats {
|
|
44
|
+
totalRecoveries: number;
|
|
45
|
+
byCategory: Record<ErrorCategory, number>;
|
|
46
|
+
byAction: Record<RecoveryActionType, number>;
|
|
47
|
+
successRate: number;
|
|
48
|
+
}
|