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.
- package/CONTEXT.md +7 -7
- package/ETHOS.md +2 -2
- package/README.md +34 -33
- package/bootstrap.ts +310 -160
- package/harness/agents/oh-planner.md +1 -1
- package/harness/agents/openhermes.md +27 -126
- package/harness/codex/AUTOPILOT.md +131 -23
- package/harness/codex/CHARTER.md +4 -5
- package/harness/lib/background/background.test.ts +216 -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 +179 -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 +7 -0
- package/harness/lib/composer/fragments/03-permissions.md +13 -0
- package/harness/lib/composer/fragments/04-task-flow.md +55 -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 +25 -0
- package/harness/lib/composer/index.ts +1 -0
- package/harness/lib/guards/guard-config.ts +72 -0
- package/harness/lib/hooks/builtins/confidence-gate-hook.ts +68 -0
- package/harness/lib/hooks/builtins/delegation-depth-hook.ts +78 -0
- package/harness/lib/hooks/builtins/dynamic-route-hook.ts +99 -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/next-route-hook.ts +24 -0
- package/harness/lib/hooks/builtins/plan-check-hook.ts +43 -0
- package/harness/lib/hooks/builtins/route-tracking-hook.ts +201 -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/builtins/subagent-failure-hook.ts +93 -0
- package/harness/lib/hooks/hooks.test.ts +1092 -0
- package/harness/lib/hooks/index.ts +42 -0
- package/harness/lib/hooks/registry.ts +416 -0
- package/harness/lib/hooks/types.ts +119 -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 +485 -0
- package/harness/lib/memory/plan-store.ts +346 -0
- package/harness/lib/plans/plan-location.ts +134 -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/routing/index.ts +21 -0
- package/harness/lib/routing/route-guidance.ts +147 -0
- package/harness/lib/routing/route-resolver.ts +58 -0
- package/harness/lib/routing/routing.test.ts +195 -0
- package/harness/lib/routing/skill-frontmatter.ts +125 -0
- package/harness/lib/routing/types.ts +52 -0
- package/harness/lib/sanity/anomaly-tracker.ts +127 -0
- package/harness/lib/sanity/checker.ts +189 -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 +175 -0
- package/harness/lib/sync/index.ts +11 -0
- package/harness/lib/sync/interfaces.ts +27 -0
- package/harness/lib/sync/plan-sync.ts +533 -0
- package/harness/lib/sync/sync.test.ts +858 -0
- package/harness/skills/oh-fusion/DEEP.md +109 -86
- package/harness/skills/oh-fusion/SKILL.md +47 -33
- package/harness/skills/oh-init/DEEP.md +2 -2
- package/harness/skills/oh-manifest/SKILL.md +2 -1
- package/harness/skills/oh-plan-review/DEEP.md +1 -1
- package/harness/skills/oh-planner/DEEP.md +3 -3
- package/harness/skills/oh-review/DEEP.md +5 -3
- package/harness/skills/oh-review/SKILL.md +1 -0
- package/harness/skills/oh-ship/SKILL.md +1 -1
- package/harness/skills/oh-skill-craft/SKILL.md +1 -4
- package/package.json +53 -55
- 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,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
|
+
}
|