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,536 @@
|
|
|
1
|
+
// PlanSync — singleton for MVCC-style concurrent-safe plan file access.
|
|
2
|
+
// Uses file-based version counters, atomic writes, and optimistic retry.
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import type { SyncPlanEntry, PlanSyncState, SyncConflict, ConflictStrategy } from "./interfaces.ts";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Constants
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
const MAX_RETRIES = 30;
|
|
13
|
+
const BASE_RETRY_DELAY_MS = 25;
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/** Sleep for ms — used between optimistic retry attempts. */
|
|
20
|
+
function sleep(ms: number): Promise<void> {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalize line endings to \n so all regexes work consistently.
|
|
26
|
+
* `\r\n` → `\n`, stray `\r` → `\n`.
|
|
27
|
+
*/
|
|
28
|
+
function normalizeEol(text: string): string {
|
|
29
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// PlanSync
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export class PlanSync {
|
|
37
|
+
private static instance: PlanSync;
|
|
38
|
+
|
|
39
|
+
private constructor() {}
|
|
40
|
+
|
|
41
|
+
/** Get the singleton instance. */
|
|
42
|
+
static getInstance(): PlanSync {
|
|
43
|
+
if (!PlanSync.instance) {
|
|
44
|
+
PlanSync.instance = new PlanSync();
|
|
45
|
+
}
|
|
46
|
+
return PlanSync.instance;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Reset singleton — used in tests to get a clean slate. */
|
|
50
|
+
static resetInstance(): void {
|
|
51
|
+
PlanSync.instance = null as unknown as PlanSync;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// -----------------------------------------------------------------------
|
|
55
|
+
// Public API
|
|
56
|
+
// -----------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read a plan markdown file and parse it into structured sync state.
|
|
60
|
+
*
|
|
61
|
+
* @param planFilePath — path to the plan `.md` file.
|
|
62
|
+
*/
|
|
63
|
+
async readPlanState(planFilePath: string): Promise<PlanSyncState> {
|
|
64
|
+
let content: string;
|
|
65
|
+
try {
|
|
66
|
+
content = await fs.promises.readFile(planFilePath, "utf8");
|
|
67
|
+
} catch {
|
|
68
|
+
return {
|
|
69
|
+
entries: new Map(),
|
|
70
|
+
version: 0,
|
|
71
|
+
lastWriter: "system",
|
|
72
|
+
lastWriteTime: Date.now(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return this.parsePlanContent(content);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Atomically write the full sync state back to the plan file.
|
|
80
|
+
* Uses temp file + rename to prevent partial writes.
|
|
81
|
+
*/
|
|
82
|
+
async writePlanState(planFilePath: string, state: PlanSyncState): Promise<void> {
|
|
83
|
+
const existing = await this.readPlanRaw(planFilePath).catch(() => "");
|
|
84
|
+
const content = this.serializePlanContent(existing, state);
|
|
85
|
+
await this.atomicWrite(planFilePath, content);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Update a single entry using optimistic concurrency:
|
|
90
|
+
* 1. Read current on-disk state
|
|
91
|
+
* 2. Increment version counters
|
|
92
|
+
* 3. Atomic write
|
|
93
|
+
* 4. Re-read and verify no conflict; retry if needed
|
|
94
|
+
*/
|
|
95
|
+
async updateEntry(planFilePath: string, entry: SyncPlanEntry): Promise<void> {
|
|
96
|
+
// Add a small random initial delay to spread out concurrent callers
|
|
97
|
+
await sleep(Math.random() * 10);
|
|
98
|
+
|
|
99
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
100
|
+
const currentState = await this.readPlanState(planFilePath);
|
|
101
|
+
const existing = currentState.entries.get(entry.id);
|
|
102
|
+
|
|
103
|
+
// Snapshot all entry versions before any writes (cross-entry conflict detection)
|
|
104
|
+
const preWriteVersions = new Map<string, number>();
|
|
105
|
+
for (const [id, e] of currentState.entries) {
|
|
106
|
+
preWriteVersions.set(id, e.version);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Merge incoming fields with existing, bump version
|
|
110
|
+
const merged: SyncPlanEntry = {
|
|
111
|
+
...existing,
|
|
112
|
+
...entry,
|
|
113
|
+
version: (existing?.version ?? 0) + 1,
|
|
114
|
+
timestamp: Date.now(),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
currentState.entries.set(entry.id, merged);
|
|
118
|
+
currentState.version = (currentState.version ?? 0) + 1;
|
|
119
|
+
currentState.lastWriter = entry.agent ?? "unknown";
|
|
120
|
+
currentState.lastWriteTime = Date.now();
|
|
121
|
+
|
|
122
|
+
await this.writePlanState(planFilePath, currentState);
|
|
123
|
+
|
|
124
|
+
// Re-read and verify ALL pre-existing entries have the expected version.
|
|
125
|
+
// For entries we didn't write, expected = pre-write version (unchanged).
|
|
126
|
+
// For our own entry, expected = pre-write version + 1 (intentionally bumped).
|
|
127
|
+
// This catches concurrent writers who overwrote any entry (including our own)
|
|
128
|
+
// between our write and verification.
|
|
129
|
+
const expectedVersions = new Map(preWriteVersions);
|
|
130
|
+
expectedVersions.set(entry.id, merged.version);
|
|
131
|
+
|
|
132
|
+
const verifiedState = await this.readPlanState(planFilePath);
|
|
133
|
+
let allConsistent = true;
|
|
134
|
+
|
|
135
|
+
for (const [id, expectedVersion] of expectedVersions) {
|
|
136
|
+
const current = verifiedState.entries.get(id);
|
|
137
|
+
if (!current || current.version !== expectedVersion) {
|
|
138
|
+
allConsistent = false;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Also check no unexpected new entries appeared (concurrent writer
|
|
144
|
+
// adding an entry we don't know about would lose their data on write).
|
|
145
|
+
if (allConsistent) {
|
|
146
|
+
for (const [id] of verifiedState.entries) {
|
|
147
|
+
if (!preWriteVersions.has(id) && id !== entry.id) {
|
|
148
|
+
allConsistent = false;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (allConsistent) return;
|
|
155
|
+
|
|
156
|
+
// Version mismatch — retry with fresh state
|
|
157
|
+
// Add jitter so concurrent writers don't stay synchronized
|
|
158
|
+
const jitter = Math.random() * BASE_RETRY_DELAY_MS;
|
|
159
|
+
await sleep(BASE_RETRY_DELAY_MS + jitter);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
throw new Error(
|
|
163
|
+
`[PlanSync] Exceeded ${MAX_RETRIES} retries updating entry "${entry.id}" in "${planFilePath}"`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Compare a local (in-memory) state against a remote (on-disk) state and
|
|
169
|
+
* return any version mismatches.
|
|
170
|
+
*/
|
|
171
|
+
detectConflicts(localState: PlanSyncState, remoteState: PlanSyncState): SyncConflict[] {
|
|
172
|
+
const conflicts: SyncConflict[] = [];
|
|
173
|
+
const allIds = new Set([
|
|
174
|
+
...localState.entries.keys(),
|
|
175
|
+
...remoteState.entries.keys(),
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
for (const entryId of allIds) {
|
|
179
|
+
const local = localState.entries.get(entryId);
|
|
180
|
+
const remote = remoteState.entries.get(entryId);
|
|
181
|
+
|
|
182
|
+
if (!remote) continue; // local-only — no conflict
|
|
183
|
+
if (!local) {
|
|
184
|
+
// remote has it, local doesn't
|
|
185
|
+
conflicts.push({
|
|
186
|
+
entryId,
|
|
187
|
+
localVersion: 0,
|
|
188
|
+
remoteVersion: remote.version,
|
|
189
|
+
localStatus: "(missing)",
|
|
190
|
+
remoteStatus: remote.status,
|
|
191
|
+
});
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (local.version !== remote.version) {
|
|
196
|
+
conflicts.push({
|
|
197
|
+
entryId,
|
|
198
|
+
localVersion: local.version,
|
|
199
|
+
remoteVersion: remote.version,
|
|
200
|
+
localStatus: local.status,
|
|
201
|
+
remoteStatus: remote.status,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return conflicts;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolve a list of conflicts using the given strategy.
|
|
211
|
+
*
|
|
212
|
+
* - `last-writer-wins`: returns empty array — caller should re-read from disk
|
|
213
|
+
* - `manual`: throws with full conflict details
|
|
214
|
+
*/
|
|
215
|
+
resolveConflicts(
|
|
216
|
+
conflicts: SyncConflict[],
|
|
217
|
+
strategy: ConflictStrategy = "last-writer-wins",
|
|
218
|
+
): SyncPlanEntry[] {
|
|
219
|
+
if (strategy === "manual") {
|
|
220
|
+
const details = conflicts
|
|
221
|
+
.map(
|
|
222
|
+
(c) =>
|
|
223
|
+
` - "${c.entryId}": local v${c.localVersion} (${c.localStatus}) vs remote v${c.remoteVersion} (${c.remoteStatus})`,
|
|
224
|
+
)
|
|
225
|
+
.join("\n");
|
|
226
|
+
throw new Error(
|
|
227
|
+
`[PlanSync] Manual conflict resolution required — ${conflicts.length} conflict(s):\n${details}`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// last-writer-wins: return empty — caller should re-read from disk
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Raw read of plan file text — used by the markdown parser.
|
|
237
|
+
*/
|
|
238
|
+
async readPlanRaw(filePath: string): Promise<string> {
|
|
239
|
+
return fs.promises.readFile(filePath, "utf8");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// -----------------------------------------------------------------------
|
|
243
|
+
// Internals — parsing
|
|
244
|
+
// -----------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Parse raw markdown content into a PlanSyncState.
|
|
248
|
+
*
|
|
249
|
+
* Strategy:
|
|
250
|
+
* 1. Read metadata (before first `##` heading) for version counters + entry versions.
|
|
251
|
+
* 2. Find `## Tasks` section and extract tasks from `### Task N: Title` headings.
|
|
252
|
+
* 3. For each task, determine status from checkboxes / active-task / completed sections.
|
|
253
|
+
*/
|
|
254
|
+
private parsePlanContent(content: string): PlanSyncState {
|
|
255
|
+
const normalized = normalizeEol(content);
|
|
256
|
+
const entries = new Map<string, SyncPlanEntry>();
|
|
257
|
+
const state: PlanSyncState = {
|
|
258
|
+
entries,
|
|
259
|
+
version: 1,
|
|
260
|
+
lastWriter: "unknown",
|
|
261
|
+
lastWriteTime: Date.now(),
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// ---- 1. Extract metadata (everything above the first ## heading) ----
|
|
265
|
+
const metaEnd = normalized.search(/\n## /);
|
|
266
|
+
const metaBlock = metaEnd >= 0 ? normalized.slice(0, metaEnd) : normalized;
|
|
267
|
+
const metaLines = metaBlock.split("\n");
|
|
268
|
+
|
|
269
|
+
for (const line of metaLines) {
|
|
270
|
+
const kv = line.match(/^(Sync-Version|Last-Writer|Last-Write-Time):\s*(.+)$/);
|
|
271
|
+
if (!kv) continue;
|
|
272
|
+
|
|
273
|
+
switch (kv[1]) {
|
|
274
|
+
case "Sync-Version":
|
|
275
|
+
state.version = parseInt(kv[2], 10) || 1;
|
|
276
|
+
break;
|
|
277
|
+
case "Last-Writer":
|
|
278
|
+
state.lastWriter = kv[2].trim();
|
|
279
|
+
break;
|
|
280
|
+
case "Last-Write-Time":
|
|
281
|
+
state.lastWriteTime = parseInt(kv[2], 10) || Date.now();
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Parse per-entry metadata (version, description, status)
|
|
287
|
+
const entryMeta = new Map<string, Record<string, string>>();
|
|
288
|
+
for (const line of metaLines) {
|
|
289
|
+
const ev = line.match(/^entry-(task-\d+)-version:\s*(\d+)$/i);
|
|
290
|
+
if (ev) {
|
|
291
|
+
const m = entryMeta.get(ev[1]) ?? {};
|
|
292
|
+
m.version = ev[2];
|
|
293
|
+
entryMeta.set(ev[1], m);
|
|
294
|
+
}
|
|
295
|
+
const ed = line.match(/^entry-(task-\d+)-description:\s*(.+)$/i);
|
|
296
|
+
if (ed) {
|
|
297
|
+
const m = entryMeta.get(ed[1]) ?? {};
|
|
298
|
+
m.description = ed[2].trim();
|
|
299
|
+
entryMeta.set(ed[1], m);
|
|
300
|
+
}
|
|
301
|
+
const es = line.match(/^entry-(task-\d+)-status:\s*(.+)$/i);
|
|
302
|
+
if (es) {
|
|
303
|
+
const m = entryMeta.get(es[1]) ?? {};
|
|
304
|
+
m.status = es[2].trim().toLowerCase();
|
|
305
|
+
entryMeta.set(es[1], m);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---- 2. Find relevant sections ----
|
|
310
|
+
const sections = this.splitIntoSections(normalized);
|
|
311
|
+
const tasksContent = sections.get("## Tasks") ?? null;
|
|
312
|
+
const completedContent = sections.get("## Completed") ?? null;
|
|
313
|
+
const activeContent = sections.get("## Active Task") ?? null;
|
|
314
|
+
|
|
315
|
+
// ---- 3. Parse tasks from ## Tasks section ----
|
|
316
|
+
if (tasksContent) {
|
|
317
|
+
const taskBlocks = this.splitTaskBlocks(tasksContent);
|
|
318
|
+
|
|
319
|
+
for (const block of taskBlocks) {
|
|
320
|
+
const hd = block.match(/^###\s+Task\s+(\d+)\s*:\s*(.+?)$/m);
|
|
321
|
+
if (!hd) continue;
|
|
322
|
+
|
|
323
|
+
const taskNum = hd[1];
|
|
324
|
+
const title = hd[2].trim();
|
|
325
|
+
const id = `task-${taskNum}`;
|
|
326
|
+
|
|
327
|
+
// Prefer metadata-stored values over heuristic parsing
|
|
328
|
+
const meta = entryMeta.get(id);
|
|
329
|
+
const heuristicStatus = this.determineTaskStatus(
|
|
330
|
+
block,
|
|
331
|
+
taskNum,
|
|
332
|
+
completedContent,
|
|
333
|
+
activeContent,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const entry: SyncPlanEntry = {
|
|
337
|
+
id,
|
|
338
|
+
description: meta?.description ?? title,
|
|
339
|
+
status: (meta?.status as SyncPlanEntry["status"]) ?? heuristicStatus,
|
|
340
|
+
timestamp: Date.now(),
|
|
341
|
+
version: meta?.version ? parseInt(meta.version, 10) : 1,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
entries.set(id, entry);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return state;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Split markdown into sections keyed by heading (e.g. `## Tasks`).
|
|
353
|
+
* Returns a Map<heading, content>.
|
|
354
|
+
*/
|
|
355
|
+
private splitIntoSections(content: string): Map<string, string> {
|
|
356
|
+
const sections = new Map<string, string>();
|
|
357
|
+
|
|
358
|
+
// Split on lines that start with `## ` (level-2 headings)
|
|
359
|
+
const parts = content.split(/\n(?=## )/);
|
|
360
|
+
|
|
361
|
+
for (const part of parts) {
|
|
362
|
+
const hd = part.match(/^(## [^\n]+)/);
|
|
363
|
+
if (!hd) continue;
|
|
364
|
+
const heading = hd[1].trim();
|
|
365
|
+
const body = part.slice(hd[1].length).trim();
|
|
366
|
+
sections.set(heading, body);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return sections;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Split the content of `## Tasks` into individual task blocks,
|
|
374
|
+
* each starting with `### Task N:`.
|
|
375
|
+
*/
|
|
376
|
+
private splitTaskBlocks(tasksContent: string): string[] {
|
|
377
|
+
// Split on lines starting with `### `
|
|
378
|
+
const parts = tasksContent.split(/\n(?=### )/);
|
|
379
|
+
return parts.filter((p) => /^###\s+Task\s+\d+\s*:/m.test(p));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Determine the status of a task based on:
|
|
384
|
+
* - Completed section listing
|
|
385
|
+
* - Active Task section
|
|
386
|
+
* - Success criteria checkboxes within the task block
|
|
387
|
+
* - Blocked/cancelled keywords
|
|
388
|
+
*/
|
|
389
|
+
private determineTaskStatus(
|
|
390
|
+
taskBlock: string,
|
|
391
|
+
taskNum: string,
|
|
392
|
+
completedContent: string | null,
|
|
393
|
+
activeContent: string | null,
|
|
394
|
+
): SyncPlanEntry["status"] {
|
|
395
|
+
// Check Completed section
|
|
396
|
+
if (completedContent) {
|
|
397
|
+
const re = new RegExp(`Task\\s+${taskNum}\\s*:`, "i");
|
|
398
|
+
if (re.test(completedContent)) return "completed";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Check Active Task section
|
|
402
|
+
if (activeContent) {
|
|
403
|
+
const re = new RegExp(`Task\\s+${taskNum}\\s*:`, "i");
|
|
404
|
+
if (re.test(activeContent)) return "in_progress";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Check success-criteria checkboxes
|
|
408
|
+
const checkboxes: string[] = [];
|
|
409
|
+
const cbRe = /^\s*-\s*\[([ xX])\]\s*/gm;
|
|
410
|
+
let m: RegExpExecArray | null;
|
|
411
|
+
while ((m = cbRe.exec(taskBlock)) !== null) {
|
|
412
|
+
checkboxes.push(m[1].toLowerCase());
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (checkboxes.length > 0) {
|
|
416
|
+
const allChecked = checkboxes.every((c) => c === "x");
|
|
417
|
+
const anyChecked = checkboxes.some((c) => c === "x");
|
|
418
|
+
if (allChecked) return "completed";
|
|
419
|
+
if (anyChecked) return "in_progress";
|
|
420
|
+
return "pending";
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Heuristic keywords
|
|
424
|
+
if (/blocked/i.test(taskBlock) && !/unblocked/i.test(taskBlock)) return "blocked";
|
|
425
|
+
if (/cancelled|canceled/i.test(taskBlock)) return "cancelled";
|
|
426
|
+
|
|
427
|
+
return "pending";
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// -----------------------------------------------------------------------
|
|
431
|
+
// Internals — serialization
|
|
432
|
+
// -----------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Serialize sync state back into plan file content.
|
|
436
|
+
*
|
|
437
|
+
* Replaces / inserts metadata lines (Sync-Version, Last-Writer, etc.)
|
|
438
|
+
* into the header block (before first `##` heading).
|
|
439
|
+
* Preserves all other content as-is.
|
|
440
|
+
*/
|
|
441
|
+
private serializePlanContent(existing: string, state: PlanSyncState): string {
|
|
442
|
+
const normalized = normalizeEol(existing);
|
|
443
|
+
|
|
444
|
+
// ---- 1. Build the set of version metadata lines ----
|
|
445
|
+
const metaToWrite = new Map<string, string>();
|
|
446
|
+
metaToWrite.set("Sync-Version", String(state.version));
|
|
447
|
+
metaToWrite.set("Last-Writer", state.lastWriter);
|
|
448
|
+
metaToWrite.set("Last-Write-Time", String(state.lastWriteTime));
|
|
449
|
+
|
|
450
|
+
for (const [, entry] of state.entries) {
|
|
451
|
+
metaToWrite.set(`entry-${entry.id}-version`, String(entry.version));
|
|
452
|
+
metaToWrite.set(`entry-${entry.id}-description`, entry.description);
|
|
453
|
+
metaToWrite.set(`entry-${entry.id}-status`, entry.status);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---- 2. Find the metadata region (before first ## heading) ----
|
|
457
|
+
const metaEnd = normalized.search(/\n## /);
|
|
458
|
+
const header = metaEnd >= 0 ? normalized.slice(0, metaEnd) : normalized;
|
|
459
|
+
const rest = metaEnd >= 0 ? normalized.slice(metaEnd) : "";
|
|
460
|
+
|
|
461
|
+
// ---- 3. Rebuild the header with updated metadata ----
|
|
462
|
+
const headerLines = header.split("\n");
|
|
463
|
+
const seen = new Set<string>();
|
|
464
|
+
const rebuiltHeader: string[] = [];
|
|
465
|
+
|
|
466
|
+
for (const line of headerLines) {
|
|
467
|
+
const kv = line.match(/^(Sync-Version|Last-Writer|Last-Write-Time|entry-[\w-]+-(?:version|description|status)):/i);
|
|
468
|
+
if (kv) {
|
|
469
|
+
const key = this.normalizeMetaKey(kv[1]);
|
|
470
|
+
const val = metaToWrite.get(key);
|
|
471
|
+
if (val !== undefined) {
|
|
472
|
+
rebuiltHeader.push(`${key}: ${val}`);
|
|
473
|
+
seen.add(key);
|
|
474
|
+
}
|
|
475
|
+
// else: stale metadata line (entry no longer in state) — silently drop
|
|
476
|
+
} else {
|
|
477
|
+
rebuiltHeader.push(line);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Append any metadata keys that weren't in the original header
|
|
482
|
+
for (const [key, val] of metaToWrite) {
|
|
483
|
+
if (!seen.has(key)) {
|
|
484
|
+
rebuiltHeader.push(`${key}: ${val}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return [...rebuiltHeader, rest].join("\n");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Normalize a metadata key to its canonical form.
|
|
493
|
+
* Handles case-insensitive matching from the regex above.
|
|
494
|
+
*/
|
|
495
|
+
private normalizeMetaKey(key: string): string {
|
|
496
|
+
const lower = key.toLowerCase();
|
|
497
|
+
if (lower === "sync-version") return "Sync-Version";
|
|
498
|
+
if (lower === "last-writer") return "Last-Writer";
|
|
499
|
+
if (lower === "last-write-time") return "Last-Write-Time";
|
|
500
|
+
// entry-*-version — preserve as-is from the map (already canonical)
|
|
501
|
+
return key;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// -----------------------------------------------------------------------
|
|
505
|
+
// Internals — atomic write
|
|
506
|
+
// -----------------------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Atomic file write: write to a temp file in the same directory, then rename.
|
|
510
|
+
* Rename is atomic on the same filesystem. On Windows, rename can EPERM
|
|
511
|
+
* under cross-device or locking scenarios — fallback writes directly to target.
|
|
512
|
+
*/
|
|
513
|
+
private async atomicWrite(filePath: string, content: string): Promise<void> {
|
|
514
|
+
const dir = path.dirname(filePath);
|
|
515
|
+
const base = path.basename(filePath);
|
|
516
|
+
// Unique suffix per write to avoid temp-file races between concurrent writers
|
|
517
|
+
const suffix = `${process.pid}_${Date.now()}`;
|
|
518
|
+
const tmpPath = path.join(dir, `.${base}.${suffix}.tmp`);
|
|
519
|
+
|
|
520
|
+
await fs.promises.writeFile(tmpPath, content, "utf8");
|
|
521
|
+
|
|
522
|
+
// Strategy:
|
|
523
|
+
// 1. Try rename (atomic on same filesystem; Windows can overwrite target)
|
|
524
|
+
// 2. EPERM fallback: write content directly (no readFile+unlink — content
|
|
525
|
+
// is already in memory, orphaned tmp is harmless until next write)
|
|
526
|
+
try {
|
|
527
|
+
await fs.promises.rename(tmpPath, filePath);
|
|
528
|
+
} catch {
|
|
529
|
+
// EPERM on Windows (cross-device or locking): write content directly
|
|
530
|
+
// to target. No readFile+unlink needed — we already have `content` in
|
|
531
|
+
// memory, and the orphaned temp file is harmless until next successful
|
|
532
|
+
// write cleans it up.
|
|
533
|
+
await fs.promises.writeFile(filePath, content, "utf8");
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|