openclaw-node-harness 2.0.4 → 2.1.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/README.md +646 -3
- package/bin/hyperagent.mjs +419 -0
- package/bin/lane-watchdog.js +23 -2
- package/bin/mesh-agent.js +439 -28
- package/bin/mesh-bridge.js +69 -3
- package/bin/mesh-health-publisher.js +41 -1
- package/bin/mesh-task-daemon.js +821 -26
- package/bin/mesh.js +411 -20
- package/config/claude-settings.json +95 -0
- package/config/daemon.json.template +2 -1
- package/config/git-hooks/pre-commit +13 -0
- package/config/git-hooks/pre-push +12 -0
- package/config/harness-rules.json +174 -0
- package/config/plan-templates/team-bugfix.yaml +52 -0
- package/config/plan-templates/team-deploy.yaml +50 -0
- package/config/plan-templates/team-feature.yaml +71 -0
- package/config/roles/qa-engineer.yaml +36 -0
- package/config/roles/solidity-dev.yaml +51 -0
- package/config/roles/tech-architect.yaml +36 -0
- package/config/rules/framework/solidity.md +22 -0
- package/config/rules/framework/typescript.md +21 -0
- package/config/rules/framework/unity.md +21 -0
- package/config/rules/universal/design-docs.md +18 -0
- package/config/rules/universal/git-hygiene.md +18 -0
- package/config/rules/universal/security.md +19 -0
- package/config/rules/universal/test-standards.md +19 -0
- package/identity/DELEGATION.md +6 -6
- package/install.sh +296 -10
- package/lib/agent-activity.js +2 -2
- package/lib/circling-parser.js +119 -0
- package/lib/exec-safety.js +105 -0
- package/lib/hyperagent-store.mjs +652 -0
- package/lib/kanban-io.js +24 -31
- package/lib/llm-providers.js +16 -0
- package/lib/mcp-knowledge/bench.mjs +118 -0
- package/lib/mcp-knowledge/core.mjs +530 -0
- package/lib/mcp-knowledge/package.json +25 -0
- package/lib/mcp-knowledge/server.mjs +252 -0
- package/lib/mcp-knowledge/test.mjs +802 -0
- package/lib/memory-budget.mjs +261 -0
- package/lib/mesh-collab.js +483 -165
- package/lib/mesh-harness.js +427 -0
- package/lib/mesh-plans.js +79 -50
- package/lib/mesh-tasks.js +132 -49
- package/lib/nats-resolve.js +4 -4
- package/lib/plan-templates.js +226 -0
- package/lib/pre-compression-flush.mjs +322 -0
- package/lib/role-loader.js +292 -0
- package/lib/rule-loader.js +358 -0
- package/lib/session-store.mjs +461 -0
- package/lib/transcript-parser.mjs +292 -0
- package/mission-control/drizzle/soul_schema_update.sql +29 -0
- package/mission-control/drizzle.config.ts +1 -4
- package/mission-control/package-lock.json +1571 -83
- package/mission-control/package.json +6 -2
- package/mission-control/scripts/gen-chronology.js +3 -3
- package/mission-control/scripts/import-pipeline-v2.js +0 -16
- package/mission-control/scripts/import-pipeline.js +0 -15
- package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
- package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
- package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
- package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
- package/mission-control/src/app/api/cowork/events/route.ts +65 -0
- package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
- package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
- package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
- package/mission-control/src/app/api/diagnostics/route.ts +97 -0
- package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
- package/mission-control/src/app/api/memory/search/route.ts +6 -3
- package/mission-control/src/app/api/mesh/events/route.ts +95 -19
- package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
- package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
- package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
- package/mission-control/src/app/api/souls/[id]/evolution/route.ts +21 -5
- package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
- package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
- package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +8 -2
- package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
- package/mission-control/src/app/api/tasks/route.ts +21 -30
- package/mission-control/src/app/api/workspace/read/route.ts +11 -0
- package/mission-control/src/app/cowork/page.tsx +261 -0
- package/mission-control/src/app/diagnostics/page.tsx +385 -0
- package/mission-control/src/app/graph/page.tsx +26 -0
- package/mission-control/src/app/memory/page.tsx +1 -1
- package/mission-control/src/app/obsidian/page.tsx +36 -6
- package/mission-control/src/app/roadmap/page.tsx +24 -0
- package/mission-control/src/app/souls/page.tsx +2 -2
- package/mission-control/src/components/board/execution-config.tsx +431 -0
- package/mission-control/src/components/board/kanban-board.tsx +75 -9
- package/mission-control/src/components/board/kanban-column.tsx +135 -19
- package/mission-control/src/components/board/task-card.tsx +55 -2
- package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
- package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
- package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
- package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
- package/mission-control/src/components/cowork/role-picker.tsx +102 -0
- package/mission-control/src/components/cowork/session-card.tsx +284 -0
- package/mission-control/src/components/layout/sidebar.tsx +39 -2
- package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
- package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
- package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
- package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
- package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
- package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
- package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
- package/mission-control/src/lib/config.ts +67 -0
- package/mission-control/src/lib/db/index.ts +85 -1
- package/mission-control/src/lib/db/schema.ts +61 -3
- package/mission-control/src/lib/hooks.ts +309 -0
- package/mission-control/src/lib/memory/entities.ts +3 -2
- package/mission-control/src/lib/memory/extract.ts +2 -1
- package/mission-control/src/lib/memory/retrieval.ts +3 -2
- package/mission-control/src/lib/nats.ts +66 -1
- package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
- package/mission-control/src/lib/parsers/transcript.ts +4 -4
- package/mission-control/src/lib/scheduler.ts +12 -11
- package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
- package/mission-control/src/lib/sync/tasks.ts +23 -1
- package/mission-control/src/lib/task-id.ts +32 -0
- package/mission-control/src/lib/tts/index.ts +33 -9
- package/mission-control/src/middleware.ts +82 -0
- package/mission-control/tsconfig.json +2 -1
- package/mission-control/vitest.config.ts +14 -0
- package/package.json +15 -2
- package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
- package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
- package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
- package/services/launchd/ai.openclaw.mission-control.plist +1 -1
- package/services/service-manifest.json +1 -1
- package/skills/cc-godmode/references/agents.md +8 -8
- package/uninstall.sh +37 -9
- package/workspace-bin/memory-daemon.mjs +199 -5
- package/workspace-bin/session-search.mjs +204 -0
- package/workspace-bin/web-fetch.mjs +65 -0
package/lib/mesh-tasks.js
CHANGED
|
@@ -22,14 +22,28 @@ const KV_BUCKET = 'MESH_TASKS';
|
|
|
22
22
|
* released — automation exhausted all retries, needs human triage
|
|
23
23
|
* cancelled — manually cancelled
|
|
24
24
|
*/
|
|
25
|
+
/**
|
|
26
|
+
* Task statuses:
|
|
27
|
+
* queued — available for claiming
|
|
28
|
+
* claimed — agent has claimed, not yet started work
|
|
29
|
+
* running — agent is actively working
|
|
30
|
+
* pending_review — work done, awaiting human approval (requires_review gate)
|
|
31
|
+
* completed — agent reports success (or human approved)
|
|
32
|
+
* failed — agent reports failure or budget exceeded
|
|
33
|
+
* released — automation exhausted all retries, needs human triage
|
|
34
|
+
* cancelled — manually cancelled
|
|
35
|
+
*/
|
|
25
36
|
const TASK_STATUS = {
|
|
26
37
|
QUEUED: 'queued',
|
|
27
38
|
CLAIMED: 'claimed',
|
|
28
39
|
RUNNING: 'running',
|
|
40
|
+
PENDING_REVIEW: 'pending_review',
|
|
29
41
|
COMPLETED: 'completed',
|
|
30
42
|
FAILED: 'failed',
|
|
31
43
|
RELEASED: 'released',
|
|
32
44
|
CANCELLED: 'cancelled',
|
|
45
|
+
PROPOSED: 'proposed',
|
|
46
|
+
REJECTED: 'rejected',
|
|
33
47
|
};
|
|
34
48
|
|
|
35
49
|
/**
|
|
@@ -53,6 +67,10 @@ function createTask({
|
|
|
53
67
|
exclude_nodes = [],
|
|
54
68
|
llm_provider = null,
|
|
55
69
|
llm_model = null,
|
|
70
|
+
plan_id = null,
|
|
71
|
+
subtask_id = null,
|
|
72
|
+
role = null,
|
|
73
|
+
requires_review = null, // null = auto-compute from mode + metric
|
|
56
74
|
}) {
|
|
57
75
|
return {
|
|
58
76
|
task_id,
|
|
@@ -64,6 +82,8 @@ function createTask({
|
|
|
64
82
|
metric, // mechanical success check (e.g. "tests pass", "val_bpb < 0.99")
|
|
65
83
|
on_fail, // what to do on failure
|
|
66
84
|
scope, // which files/paths the agent can touch
|
|
85
|
+
role, // role profile ID (e.g. "solidity-dev") for prompt injection + output validation
|
|
86
|
+
requires_review, // null = auto-computed by daemon; true/false = explicit override
|
|
67
87
|
|
|
68
88
|
// Standard fields
|
|
69
89
|
success_criteria,
|
|
@@ -94,6 +114,10 @@ function createTask({
|
|
|
94
114
|
budget_deadline: null, // set when claimed: claimed_at + budget_minutes
|
|
95
115
|
last_activity: null, // updated by agent heartbeats — stall detection key
|
|
96
116
|
|
|
117
|
+
// Plan back-reference (O(1) lookup in checkPlanProgress)
|
|
118
|
+
plan_id, // parent plan ID (null if standalone task)
|
|
119
|
+
subtask_id, // subtask ID within the plan (null if standalone task)
|
|
120
|
+
|
|
97
121
|
// Result (filled by agent)
|
|
98
122
|
result: null, // { success, summary, artifacts, attempts }
|
|
99
123
|
attempts: [], // log of approaches tried
|
|
@@ -118,6 +142,28 @@ class TaskStore {
|
|
|
118
142
|
return JSON.parse(sc.decode(entry.value));
|
|
119
143
|
}
|
|
120
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Compare-and-swap helper: read → mutate → write with optimistic concurrency.
|
|
147
|
+
* Re-reads and retries on conflict (up to maxRetries).
|
|
148
|
+
* mutateFn receives the parsed data and must return the updated object, or falsy to skip.
|
|
149
|
+
*/
|
|
150
|
+
async _updateWithCAS(key, mutateFn, maxRetries = 3) {
|
|
151
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
152
|
+
const entry = await this.kv.get(key);
|
|
153
|
+
if (!entry) return null;
|
|
154
|
+
const data = JSON.parse(sc.decode(entry.value));
|
|
155
|
+
const updated = mutateFn(data);
|
|
156
|
+
if (!updated) return null;
|
|
157
|
+
try {
|
|
158
|
+
await this.kv.put(key, sc.encode(JSON.stringify(updated)), { previousSeq: entry.revision });
|
|
159
|
+
return updated;
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (attempt === maxRetries - 1) throw err;
|
|
162
|
+
// conflict — retry
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
121
167
|
async delete(taskId) {
|
|
122
168
|
await this.kv.delete(taskId);
|
|
123
169
|
}
|
|
@@ -189,67 +235,106 @@ class TaskStore {
|
|
|
189
235
|
if (claimable.length === 0) return null;
|
|
190
236
|
|
|
191
237
|
const task = claimable[0];
|
|
192
|
-
task.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
238
|
+
const result = await this._updateWithCAS(task.task_id, (t) => {
|
|
239
|
+
// Re-check status under CAS — another node may have claimed it
|
|
240
|
+
if (t.status !== TASK_STATUS.QUEUED) return null;
|
|
241
|
+
t.status = TASK_STATUS.CLAIMED;
|
|
242
|
+
t.owner = nodeId;
|
|
243
|
+
t.claimed_at = new Date().toISOString();
|
|
244
|
+
const budgetMs = (t.budget_minutes || 30) * 60 * 1000;
|
|
245
|
+
t.budget_deadline = new Date(Date.now() + budgetMs).toISOString();
|
|
246
|
+
return t;
|
|
247
|
+
});
|
|
248
|
+
return result;
|
|
200
249
|
}
|
|
201
250
|
|
|
202
251
|
/**
|
|
203
252
|
* Mark a task as running (agent started work).
|
|
204
253
|
*/
|
|
205
254
|
async markRunning(taskId) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return task;
|
|
255
|
+
return this._updateWithCAS(taskId, (task) => {
|
|
256
|
+
task.status = TASK_STATUS.RUNNING;
|
|
257
|
+
task.started_at = new Date().toISOString();
|
|
258
|
+
return task;
|
|
259
|
+
});
|
|
212
260
|
}
|
|
213
261
|
|
|
214
262
|
/**
|
|
215
263
|
* Mark a task as completed with result.
|
|
216
264
|
*/
|
|
217
265
|
async markCompleted(taskId, result) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
266
|
+
return this._updateWithCAS(taskId, (task) => {
|
|
267
|
+
task.status = TASK_STATUS.COMPLETED;
|
|
268
|
+
task.completed_at = new Date().toISOString();
|
|
269
|
+
task.result = result;
|
|
270
|
+
return task;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Mark a task as pending_review (work done, needs human approval).
|
|
276
|
+
* Stores the result but doesn't transition to completed.
|
|
277
|
+
*/
|
|
278
|
+
async markPendingReview(taskId, result) {
|
|
279
|
+
return this._updateWithCAS(taskId, (task) => {
|
|
280
|
+
task.status = TASK_STATUS.PENDING_REVIEW;
|
|
281
|
+
task.result = result;
|
|
282
|
+
task.review_requested_at = new Date().toISOString();
|
|
283
|
+
return task;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Approve a pending_review task → completed.
|
|
289
|
+
*/
|
|
290
|
+
async markApproved(taskId) {
|
|
291
|
+
return this._updateWithCAS(taskId, (task) => {
|
|
292
|
+
if (task.status !== TASK_STATUS.PENDING_REVIEW) return null;
|
|
293
|
+
task.status = TASK_STATUS.COMPLETED;
|
|
294
|
+
task.completed_at = new Date().toISOString();
|
|
295
|
+
task.reviewed_by = 'human';
|
|
296
|
+
return task;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Reject a pending_review task → re-queue with reason.
|
|
302
|
+
*/
|
|
303
|
+
async markRejected(taskId, reason) {
|
|
304
|
+
return this._updateWithCAS(taskId, (task) => {
|
|
305
|
+
if (task.status !== TASK_STATUS.PENDING_REVIEW) return null;
|
|
306
|
+
task.status = TASK_STATUS.QUEUED;
|
|
307
|
+
task.rejection_reason = reason;
|
|
308
|
+
task.result = null;
|
|
309
|
+
task.review_requested_at = null;
|
|
310
|
+
return task;
|
|
311
|
+
});
|
|
225
312
|
}
|
|
226
313
|
|
|
227
314
|
/**
|
|
228
315
|
* Mark a task as failed with reason.
|
|
229
316
|
*/
|
|
230
317
|
async markFailed(taskId, reason, attempts = []) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return task;
|
|
318
|
+
return this._updateWithCAS(taskId, (task) => {
|
|
319
|
+
task.status = TASK_STATUS.FAILED;
|
|
320
|
+
task.completed_at = new Date().toISOString();
|
|
321
|
+
task.result = { success: false, summary: reason };
|
|
322
|
+
task.attempts = attempts;
|
|
323
|
+
return task;
|
|
324
|
+
});
|
|
239
325
|
}
|
|
240
326
|
|
|
241
327
|
/**
|
|
242
328
|
* Log an attempt on a task (agent tried something, may or may not have worked).
|
|
243
329
|
*/
|
|
244
330
|
async logAttempt(taskId, attempt) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
331
|
+
return this._updateWithCAS(taskId, (task) => {
|
|
332
|
+
task.attempts.push({
|
|
333
|
+
...attempt,
|
|
334
|
+
timestamp: new Date().toISOString(),
|
|
335
|
+
});
|
|
336
|
+
return task;
|
|
250
337
|
});
|
|
251
|
-
await this.put(task);
|
|
252
|
-
return task;
|
|
253
338
|
}
|
|
254
339
|
|
|
255
340
|
/**
|
|
@@ -257,25 +342,23 @@ class TaskStore {
|
|
|
257
342
|
* Distinct from failed: failed = "didn't work", released = "we tried everything."
|
|
258
343
|
*/
|
|
259
344
|
async markReleased(taskId, reason, attempts = []) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
return task;
|
|
345
|
+
return this._updateWithCAS(taskId, (task) => {
|
|
346
|
+
task.status = TASK_STATUS.RELEASED;
|
|
347
|
+
task.completed_at = new Date().toISOString();
|
|
348
|
+
task.result = { success: false, summary: reason, released: true };
|
|
349
|
+
if (attempts.length > 0) task.attempts = attempts;
|
|
350
|
+
return task;
|
|
351
|
+
});
|
|
268
352
|
}
|
|
269
353
|
|
|
270
354
|
/**
|
|
271
355
|
* Update last_activity timestamp (agent heartbeat).
|
|
272
356
|
*/
|
|
273
357
|
async touchActivity(taskId) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
return task;
|
|
358
|
+
return this._updateWithCAS(taskId, (task) => {
|
|
359
|
+
task.last_activity = new Date().toISOString();
|
|
360
|
+
return task;
|
|
361
|
+
});
|
|
279
362
|
}
|
|
280
363
|
|
|
281
364
|
/**
|
package/lib/nats-resolve.js
CHANGED
|
@@ -37,7 +37,7 @@ function resolveNatsUrl() {
|
|
|
37
37
|
if (fs.existsSync(envFile)) {
|
|
38
38
|
const content = fs.readFileSync(envFile, 'utf8');
|
|
39
39
|
const match = content.match(/^\s*OPENCLAW_NATS\s*=\s*(.+)/m);
|
|
40
|
-
if (match && match[1].trim()) return match[1].trim();
|
|
40
|
+
if (match && match[1].trim()) return match[1].trim().replace(/^["']|["']$/g, '');
|
|
41
41
|
}
|
|
42
42
|
} catch {
|
|
43
43
|
// File unreadable — fall through silently
|
|
@@ -49,7 +49,7 @@ function resolveNatsUrl() {
|
|
|
49
49
|
if (fs.existsSync(meshConfig)) {
|
|
50
50
|
const content = fs.readFileSync(meshConfig, 'utf8');
|
|
51
51
|
const match = content.match(/^\s*OPENCLAW_NATS\s*=\s*(.+)/m);
|
|
52
|
-
if (match && match[1].trim()) return match[1].trim();
|
|
52
|
+
if (match && match[1].trim()) return match[1].trim().replace(/^["']|["']$/g, '');
|
|
53
53
|
}
|
|
54
54
|
} catch {
|
|
55
55
|
// File unreadable — fall through silently
|
|
@@ -73,7 +73,7 @@ function resolveNatsToken() {
|
|
|
73
73
|
if (fs.existsSync(envFile)) {
|
|
74
74
|
const content = fs.readFileSync(envFile, 'utf8');
|
|
75
75
|
const match = content.match(/^\s*OPENCLAW_NATS_TOKEN\s*=\s*(.+)/m);
|
|
76
|
-
if (match && match[1].trim()) return match[1].trim();
|
|
76
|
+
if (match && match[1].trim()) return match[1].trim().replace(/^["']|["']$/g, '');
|
|
77
77
|
}
|
|
78
78
|
} catch {}
|
|
79
79
|
|
|
@@ -83,7 +83,7 @@ function resolveNatsToken() {
|
|
|
83
83
|
if (fs.existsSync(meshConfig)) {
|
|
84
84
|
const content = fs.readFileSync(meshConfig, 'utf8');
|
|
85
85
|
const match = content.match(/^\s*OPENCLAW_NATS_TOKEN\s*=\s*(.+)/m);
|
|
86
|
-
if (match && match[1].trim()) return match[1].trim();
|
|
86
|
+
if (match && match[1].trim()) return match[1].trim().replace(/^["']|["']$/g, '');
|
|
87
87
|
}
|
|
88
88
|
} catch {}
|
|
89
89
|
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan-templates.js — Load, validate, and instantiate plan templates.
|
|
3
|
+
*
|
|
4
|
+
* Templates are YAML files in .openclaw/plan-templates/ that define
|
|
5
|
+
* reusable multi-phase pipelines. Instantiation substitutes context
|
|
6
|
+
* variables and produces a plan ready for mesh.plans.create.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const yaml = require('js-yaml');
|
|
12
|
+
const { autoRoutePlan, createPlan } = require('./mesh-plans');
|
|
13
|
+
|
|
14
|
+
// ── Template Loading ──────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Load a single template from a YAML file.
|
|
18
|
+
*/
|
|
19
|
+
function loadTemplate(templatePath) {
|
|
20
|
+
const content = fs.readFileSync(templatePath, 'utf-8');
|
|
21
|
+
const template = yaml.load(content);
|
|
22
|
+
|
|
23
|
+
if (!template.id) {
|
|
24
|
+
template.id = path.basename(templatePath, '.yaml');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return template;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* List all available templates in a directory.
|
|
32
|
+
* Returns array of { id, name, description, file }.
|
|
33
|
+
*/
|
|
34
|
+
function listTemplates(templatesDir) {
|
|
35
|
+
if (!fs.existsSync(templatesDir)) return [];
|
|
36
|
+
|
|
37
|
+
return fs.readdirSync(templatesDir)
|
|
38
|
+
.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
|
|
39
|
+
.map(file => {
|
|
40
|
+
try {
|
|
41
|
+
const template = loadTemplate(path.join(templatesDir, file));
|
|
42
|
+
return {
|
|
43
|
+
id: template.id,
|
|
44
|
+
name: template.name || template.id,
|
|
45
|
+
description: template.description || '',
|
|
46
|
+
file,
|
|
47
|
+
};
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Template Validation ───────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate a template for structural correctness.
|
|
59
|
+
* Returns { valid: boolean, errors: string[] }.
|
|
60
|
+
*/
|
|
61
|
+
function validateTemplate(template) {
|
|
62
|
+
const errors = [];
|
|
63
|
+
|
|
64
|
+
if (!template.id) errors.push('Missing template id');
|
|
65
|
+
if (!template.phases || !Array.isArray(template.phases)) {
|
|
66
|
+
errors.push('Missing or invalid phases array');
|
|
67
|
+
return { valid: false, errors };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const allIds = new Set();
|
|
71
|
+
const allSubtasks = [];
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < template.phases.length; i++) {
|
|
74
|
+
const phase = template.phases[i];
|
|
75
|
+
if (!phase.subtasks || !Array.isArray(phase.subtasks)) {
|
|
76
|
+
errors.push(`Phase ${i}: missing subtasks array`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const st of phase.subtasks) {
|
|
81
|
+
if (!st.id) {
|
|
82
|
+
errors.push(`Phase ${i}: subtask missing id`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (allIds.has(st.id)) {
|
|
86
|
+
errors.push(`Duplicate subtask id: ${st.id}`);
|
|
87
|
+
}
|
|
88
|
+
allIds.add(st.id);
|
|
89
|
+
allSubtasks.push(st);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check dependency references
|
|
94
|
+
for (const st of allSubtasks) {
|
|
95
|
+
if (st.depends_on) {
|
|
96
|
+
for (const dep of st.depends_on) {
|
|
97
|
+
if (!allIds.has(dep)) {
|
|
98
|
+
errors.push(`Subtask ${st.id}: depends on unknown subtask '${dep}'`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check for circular dependencies (simple DFS)
|
|
105
|
+
const visiting = new Set();
|
|
106
|
+
const visited = new Set();
|
|
107
|
+
const stMap = new Map(allSubtasks.map(st => [st.id, st]));
|
|
108
|
+
|
|
109
|
+
function hasCycle(id) {
|
|
110
|
+
if (visiting.has(id)) return true;
|
|
111
|
+
if (visited.has(id)) return false;
|
|
112
|
+
visiting.add(id);
|
|
113
|
+
const st = stMap.get(id);
|
|
114
|
+
if (st && st.depends_on) {
|
|
115
|
+
for (const dep of st.depends_on) {
|
|
116
|
+
if (hasCycle(dep)) return true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
visiting.delete(id);
|
|
120
|
+
visited.add(id);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const id of allIds) {
|
|
125
|
+
if (hasCycle(id)) {
|
|
126
|
+
errors.push(`Circular dependency detected involving subtask '${id}'`);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Validate delegation modes
|
|
132
|
+
const validModes = ['solo_mesh', 'collab_mesh', 'local', 'soul', 'human', 'auto'];
|
|
133
|
+
for (const st of allSubtasks) {
|
|
134
|
+
if (st.delegation && st.delegation.mode && !validModes.includes(st.delegation.mode)) {
|
|
135
|
+
errors.push(`Subtask ${st.id}: invalid delegation mode '${st.delegation.mode}'`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { valid: errors.length === 0, errors };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Template Instantiation ────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Substitute {{context}} and {{vars.key}} in all string fields.
|
|
146
|
+
*/
|
|
147
|
+
function substituteVars(obj, context, vars = {}) {
|
|
148
|
+
if (typeof obj === 'string') {
|
|
149
|
+
let result = obj.replace(/\{\{context\}\}/g, context);
|
|
150
|
+
for (const [key, val] of Object.entries(vars)) {
|
|
151
|
+
result = result.replace(new RegExp(`\\{\\{vars\\.${key}\\}\\}`, 'g'), String(val));
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
if (Array.isArray(obj)) {
|
|
156
|
+
return obj.map(item => substituteVars(item, context, vars));
|
|
157
|
+
}
|
|
158
|
+
if (obj && typeof obj === 'object') {
|
|
159
|
+
const result = {};
|
|
160
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
161
|
+
result[key] = substituteVars(val, context, vars);
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
return obj;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Instantiate a template into a plan-ready object.
|
|
170
|
+
*
|
|
171
|
+
* @param {object} template — loaded template
|
|
172
|
+
* @param {string} context — main context string (substituted into {{context}})
|
|
173
|
+
* @param {object} opts — { parent_task_id, vars, planner }
|
|
174
|
+
* @returns {object} — plan object ready for mesh.plans.create
|
|
175
|
+
*/
|
|
176
|
+
function instantiateTemplate(template, context, opts = {}) {
|
|
177
|
+
const { parent_task_id, vars = {}, planner = 'daedalus' } = opts;
|
|
178
|
+
|
|
179
|
+
// Flatten phases into subtask array with dependency wiring
|
|
180
|
+
const subtasks = [];
|
|
181
|
+
|
|
182
|
+
for (const phase of template.phases) {
|
|
183
|
+
for (const stSpec of phase.subtasks) {
|
|
184
|
+
const substituted = substituteVars(stSpec, context, vars);
|
|
185
|
+
|
|
186
|
+
const subtask = {
|
|
187
|
+
subtask_id: substituted.id,
|
|
188
|
+
title: substituted.title || substituted.id,
|
|
189
|
+
description: substituted.description || '',
|
|
190
|
+
delegation: substituted.delegation || { mode: 'auto' },
|
|
191
|
+
budget_minutes: parseInt(substituted.budget_minutes) || 15,
|
|
192
|
+
metric: substituted.metric || null,
|
|
193
|
+
scope: substituted.scope || [],
|
|
194
|
+
success_criteria: substituted.success_criteria || [],
|
|
195
|
+
depends_on: substituted.depends_on || [],
|
|
196
|
+
critical: substituted.critical || false,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
subtasks.push(subtask);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Create the plan
|
|
204
|
+
const plan = createPlan({
|
|
205
|
+
parent_task_id: parent_task_id || `TEMPLATE-${template.id}-${Date.now()}`,
|
|
206
|
+
title: substituteVars(template.name || template.id, context, vars),
|
|
207
|
+
description: substituteVars(template.description || '', context, vars),
|
|
208
|
+
planner,
|
|
209
|
+
failure_policy: template.failure_policy || 'continue_best_effort',
|
|
210
|
+
requires_approval: template.requires_approval !== false, // default true
|
|
211
|
+
subtasks,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Auto-route any subtasks with mode: 'auto'
|
|
215
|
+
autoRoutePlan(plan);
|
|
216
|
+
|
|
217
|
+
return plan;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
loadTemplate,
|
|
222
|
+
listTemplates,
|
|
223
|
+
validateTemplate,
|
|
224
|
+
instantiateTemplate,
|
|
225
|
+
substituteVars,
|
|
226
|
+
};
|