openhermes 4.9.2 → 4.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CONTEXT.md +7 -7
  2. package/ETHOS.md +2 -2
  3. package/README.md +34 -33
  4. package/bootstrap.ts +310 -160
  5. package/harness/agents/oh-planner.md +1 -1
  6. package/harness/agents/openhermes.md +27 -126
  7. package/harness/codex/AUTOPILOT.md +131 -23
  8. package/harness/codex/CHARTER.md +4 -5
  9. package/harness/lib/background/background.test.ts +216 -0
  10. package/harness/lib/background/index.ts +7 -0
  11. package/harness/lib/background/interfaces.ts +31 -0
  12. package/harness/lib/background/manager.ts +320 -0
  13. package/harness/lib/composer/compose.test.ts +179 -0
  14. package/harness/lib/composer/compose.ts +65 -0
  15. package/harness/lib/composer/fragments/01-identity.md +1 -0
  16. package/harness/lib/composer/fragments/02-delegation.md +7 -0
  17. package/harness/lib/composer/fragments/03-permissions.md +13 -0
  18. package/harness/lib/composer/fragments/04-task-flow.md +55 -0
  19. package/harness/lib/composer/fragments/05-confidence.md +5 -0
  20. package/harness/lib/composer/fragments/06-parallelization.md +17 -0
  21. package/harness/lib/composer/fragments/07-shell.md +41 -0
  22. package/harness/lib/composer/fragments/08-routing.md +8 -0
  23. package/harness/lib/composer/fragments/09-guardrails.md +25 -0
  24. package/harness/lib/composer/index.ts +1 -0
  25. package/harness/lib/guards/guard-config.ts +72 -0
  26. package/harness/lib/hooks/builtins/confidence-gate-hook.ts +68 -0
  27. package/harness/lib/hooks/builtins/delegation-depth-hook.ts +78 -0
  28. package/harness/lib/hooks/builtins/dynamic-route-hook.ts +99 -0
  29. package/harness/lib/hooks/builtins/error-recovery-hook.ts +107 -0
  30. package/harness/lib/hooks/builtins/memory-sync-hook.ts +73 -0
  31. package/harness/lib/hooks/builtins/next-route-hook.ts +24 -0
  32. package/harness/lib/hooks/builtins/plan-check-hook.ts +43 -0
  33. package/harness/lib/hooks/builtins/route-tracking-hook.ts +201 -0
  34. package/harness/lib/hooks/builtins/sanity-check-hook.ts +52 -0
  35. package/harness/lib/hooks/builtins/shell-detect-hook.ts +96 -0
  36. package/harness/lib/hooks/builtins/subagent-failure-hook.ts +93 -0
  37. package/harness/lib/hooks/hooks.test.ts +1092 -0
  38. package/harness/lib/hooks/index.ts +42 -0
  39. package/harness/lib/hooks/registry.ts +416 -0
  40. package/harness/lib/hooks/types.ts +119 -0
  41. package/harness/lib/memory/index.ts +18 -0
  42. package/harness/lib/memory/interfaces.ts +53 -0
  43. package/harness/lib/memory/memory-manager.ts +205 -0
  44. package/harness/lib/memory/memory.test.ts +485 -0
  45. package/harness/lib/memory/plan-store.ts +346 -0
  46. package/harness/lib/plans/plan-location.ts +134 -0
  47. package/harness/lib/recovery/handler.ts +243 -0
  48. package/harness/lib/recovery/index.ts +14 -0
  49. package/harness/lib/recovery/interfaces.ts +48 -0
  50. package/harness/lib/recovery/patterns.ts +149 -0
  51. package/harness/lib/recovery/recovery.test.ts +312 -0
  52. package/harness/lib/routing/index.ts +21 -0
  53. package/harness/lib/routing/route-guidance.ts +147 -0
  54. package/harness/lib/routing/route-resolver.ts +58 -0
  55. package/harness/lib/routing/routing.test.ts +195 -0
  56. package/harness/lib/routing/skill-frontmatter.ts +125 -0
  57. package/harness/lib/routing/types.ts +52 -0
  58. package/harness/lib/sanity/anomaly-tracker.ts +127 -0
  59. package/harness/lib/sanity/checker.ts +189 -0
  60. package/harness/lib/sanity/index.ts +13 -0
  61. package/harness/lib/sanity/interfaces.ts +24 -0
  62. package/harness/lib/sanity/sanity.test.ts +472 -0
  63. package/harness/lib/sync/file-watcher.ts +175 -0
  64. package/harness/lib/sync/index.ts +11 -0
  65. package/harness/lib/sync/interfaces.ts +27 -0
  66. package/harness/lib/sync/plan-sync.ts +533 -0
  67. package/harness/lib/sync/sync.test.ts +858 -0
  68. package/harness/skills/oh-fusion/DEEP.md +109 -86
  69. package/harness/skills/oh-fusion/SKILL.md +47 -33
  70. package/harness/skills/oh-init/DEEP.md +2 -2
  71. package/harness/skills/oh-manifest/SKILL.md +2 -1
  72. package/harness/skills/oh-plan-review/DEEP.md +1 -1
  73. package/harness/skills/oh-planner/DEEP.md +3 -3
  74. package/harness/skills/oh-review/DEEP.md +5 -3
  75. package/harness/skills/oh-review/SKILL.md +1 -0
  76. package/harness/skills/oh-ship/SKILL.md +1 -1
  77. package/harness/skills/oh-skill-craft/SKILL.md +1 -4
  78. package/package.json +53 -55
  79. package/tsconfig.json +1 -1
  80. package/harness/commands/oh-doctor.md +0 -205
  81. package/harness/commands/oh-log.md +0 -18
  82. package/harness/skills/oh-learn/DEEP.md +0 -44
  83. package/harness/skills/oh-learn/SKILL.md +0 -30
  84. package/scripts/count-tokens.mjs +0 -158
  85. package/scripts/oh-doctor.ps1 +0 -342
@@ -0,0 +1,346 @@
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
+
236
+ // ---------------------------------------------------------------------------
237
+ // Internal parsing helpers
238
+ // ---------------------------------------------------------------------------
239
+
240
+ /**
241
+ * Parse a plan document string into structured PlanData.
242
+ */
243
+ function parsePlanDocument(source: string): PlanData {
244
+ const tasks: MemoryPlanEntry[] = [];
245
+ const memory: MemoryEntry[] = [];
246
+ const findings: Finding[] = [];
247
+ const decisions: Decision[] = [];
248
+
249
+ const lines = source.split(/\r?\n/);
250
+ let section: string | null = null;
251
+
252
+ for (const raw of lines) {
253
+ const line = raw.trim();
254
+
255
+ // Section detection
256
+ const sectionMatch = line.match(/^##\s+(.+)$/);
257
+ if (sectionMatch) {
258
+ section = sectionMatch[1].toLowerCase();
259
+ continue;
260
+ }
261
+
262
+ if (!section || !line) continue;
263
+
264
+ switch (section) {
265
+ case "tasks": {
266
+ const taskMatch = line.match(/^-\s+\[([ x])\]\s+(.+)$/);
267
+ if (taskMatch) {
268
+ const depMatch = taskMatch[2].match(/^(.+?)\s+\[depends:\s+(.+?)\]$/);
269
+ tasks.push({
270
+ id: randomUUID(),
271
+ description: depMatch ? depMatch[1].trim() : taskMatch[2].trim(),
272
+ status: taskMatch[1] === "x" ? "completed" : "pending",
273
+ dependsOn: depMatch ? depMatch[2].split(/,\s*/) : [],
274
+ });
275
+ }
276
+ break;
277
+ }
278
+
279
+ case "memory": {
280
+ const memMatch = line.match(
281
+ /^-\s+\[(\w+)\]\s+\(([\d.]+)\)\s+(.+?)(?:\s+(\{.*\}))?$/,
282
+ );
283
+ if (memMatch) {
284
+ let metadata: Record<string, string> | undefined;
285
+ try {
286
+ if (memMatch[4]) metadata = JSON.parse(memMatch[4]);
287
+ } catch {
288
+ // ignore malformed metadata
289
+ }
290
+ memory.push({
291
+ id: randomUUID(),
292
+ level: memMatch[1] as MemoryLevel,
293
+ importance: parseFloat(memMatch[2]),
294
+ content: memMatch[3].trim(),
295
+ timestamp: Date.now(),
296
+ metadata,
297
+ });
298
+ }
299
+ break;
300
+ }
301
+
302
+ case "findings": {
303
+ const findingMatch = line.match(
304
+ /^-\s+\[(\w+)\]\s+(.+?)\s+_\(session:\s+(.+?)\)_\s*$/,
305
+ );
306
+ if (findingMatch) {
307
+ findings.push({
308
+ id: randomUUID(),
309
+ severity: findingMatch[1] as Finding["severity"],
310
+ description: findingMatch[2].trim(),
311
+ sessionId: findingMatch[3].trim(),
312
+ timestamp: Date.now(),
313
+ });
314
+ }
315
+ break;
316
+ }
317
+
318
+ case "decisions": {
319
+ const decMatch = line.match(
320
+ /^-\s+\*\*(.+?)\*\*\s*[—–-]+\s*(.+?)\s+_\(session:\s+(.+?)\)_\s*$/,
321
+ );
322
+ if (decMatch) {
323
+ decisions.push({
324
+ id: randomUUID(),
325
+ description: decMatch[1].trim(),
326
+ rationale: decMatch[2].trim(),
327
+ sessionId: decMatch[3].trim(),
328
+ timestamp: Date.now(),
329
+ });
330
+ }
331
+ break;
332
+ }
333
+ }
334
+ }
335
+
336
+ return { tasks, memory, findings, decisions };
337
+ }
338
+
339
+ /**
340
+ * Extract the header portion of a plan file (everything before the first ##).
341
+ */
342
+ function extractHeader(source: string): string | null {
343
+ const idx = source.search(/^## /m);
344
+ if (idx < 0) return source.trim();
345
+ return source.slice(0, idx).trim();
346
+ }
@@ -0,0 +1,134 @@
1
+ import fs from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ let _planStorageOverride: string | undefined
6
+
7
+ export interface PlanAccess {
8
+ path: string
9
+ status: string | null
10
+ objective: string | null
11
+ summary: string | null
12
+ }
13
+
14
+ export function setPlanStorageDirForTest(dir: string | undefined): void {
15
+ _planStorageOverride = dir
16
+ }
17
+
18
+ export function planStorageDir(): string {
19
+ return _planStorageOverride ?? path.join(os.homedir(), ".local", "share", "openhermes", "plans")
20
+ }
21
+
22
+ function getProjectName(projectDir: string): string {
23
+ return path.basename(projectDir)
24
+ }
25
+
26
+ function ensureDir(dir: string): void {
27
+ try {
28
+ if (!fs.existsSync(dir)) {
29
+ fs.mkdirSync(dir, { recursive: true })
30
+ }
31
+ } catch (err) {
32
+ const msg = err instanceof Error ? err.message : String(err)
33
+ console.error(`[openhermes] Failed to create directory ${dir}: ${msg}`)
34
+ }
35
+ }
36
+
37
+ function readPlanAccess(filePath: string): PlanAccess | null {
38
+ if (!fs.existsSync(filePath)) return null
39
+ const source = fs.readFileSync(filePath, "utf8")
40
+ const status = source.match(/^Status:\s*(.+)$/m)?.[1]?.trim() ?? null
41
+ const objective = source.match(/^Objective:\s*(.+)$/m)?.[1]?.trim() ?? null
42
+ if (!status && !objective) return null
43
+ const parts = [status ? `status=${status}` : null, objective ? `objective=${objective}` : null].filter(Boolean)
44
+ return {
45
+ path: filePath,
46
+ status,
47
+ objective,
48
+ summary: `Active plan: ${parts.join(" | ")}`,
49
+ }
50
+ }
51
+
52
+ export function resolvePlanAccess(projectDir: string): PlanAccess | null {
53
+ const latest = findLatestPlanFile(projectDir)
54
+ if (!latest) return null
55
+ return readPlanAccess(latest)
56
+ }
57
+
58
+ export function findLatestPlanFile(projectDir: string): string | null {
59
+ const projectName = getProjectName(projectDir)
60
+ const storage = planStorageDir()
61
+ const projectDirPath = path.join(storage, projectName)
62
+ if (!fs.existsSync(projectDirPath)) return null
63
+ let latest: string | null = null
64
+ let highest = -1
65
+ try {
66
+ for (const entry of fs.readdirSync(projectDirPath)) {
67
+ const m = entry.match(/^plan-(\d{3})\.md$/)
68
+ if (m) {
69
+ const n = parseInt(m[1], 10)
70
+ if (n > highest) {
71
+ highest = n
72
+ latest = path.join(projectDirPath, entry)
73
+ }
74
+ }
75
+ }
76
+ } catch {
77
+ return null
78
+ }
79
+ return latest
80
+ }
81
+
82
+ export function ensurePlanFile(projectDir: string): string {
83
+ const access = resolvePlanAccess(projectDir)
84
+ if (access?.status === "active" || access?.status === "in-progress") {
85
+ return access.path
86
+ }
87
+
88
+ const projectName = getProjectName(projectDir)
89
+ const storage = planStorageDir()
90
+ const projectDirPath = path.join(storage, projectName)
91
+ ensureDir(projectDirPath)
92
+
93
+ const latest = access?.path ?? findLatestPlanFile(projectDir)
94
+ let nextSeq = 1
95
+ if (latest) {
96
+ const m = path.basename(latest).match(/^plan-(\d{3})\.md$/)
97
+ if (m) nextSeq = parseInt(m[1], 10) + 1
98
+ }
99
+
100
+ const seq = String(nextSeq).padStart(3, "0")
101
+ const planId = `${projectName}/plan-${seq}.md`
102
+ const planPath = path.join(projectDirPath, `plan-${seq}.md`)
103
+ const now = new Date().toISOString().replace("T", " ").slice(0, 16)
104
+
105
+ const content = [
106
+ `# PLAN: ${projectName}`,
107
+ "",
108
+ `Plan ID: ${planId}`,
109
+ `Project: ${projectName}`,
110
+ `Status: active`,
111
+ `Created: ${now}`,
112
+ `Updated: ${now}`,
113
+ `Project Path: ${projectDir}`,
114
+ `Plan Path: ${planPath}`,
115
+ `Objective: (pending classification)`,
116
+ "",
117
+ "## Tasks",
118
+ "",
119
+ "- [ ] (discoverable — pending classification)",
120
+ "",
121
+ ].join("\n")
122
+
123
+ try {
124
+ fs.writeFileSync(planPath, content, "utf8")
125
+ } catch (err) {
126
+ const msg = err instanceof Error ? err.message : String(err)
127
+ console.error(`[openhermes] Failed to write plan file ${planPath}: ${msg}`)
128
+ }
129
+ return planPath
130
+ }
131
+
132
+ export function readPlanSummary(projectDir: string): string | null {
133
+ return resolvePlanAccess(projectDir)?.summary ?? null
134
+ }