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.
Files changed (134) hide show
  1. package/README.md +646 -3
  2. package/bin/hyperagent.mjs +419 -0
  3. package/bin/lane-watchdog.js +23 -2
  4. package/bin/mesh-agent.js +439 -28
  5. package/bin/mesh-bridge.js +69 -3
  6. package/bin/mesh-health-publisher.js +41 -1
  7. package/bin/mesh-task-daemon.js +821 -26
  8. package/bin/mesh.js +411 -20
  9. package/config/claude-settings.json +95 -0
  10. package/config/daemon.json.template +2 -1
  11. package/config/git-hooks/pre-commit +13 -0
  12. package/config/git-hooks/pre-push +12 -0
  13. package/config/harness-rules.json +174 -0
  14. package/config/plan-templates/team-bugfix.yaml +52 -0
  15. package/config/plan-templates/team-deploy.yaml +50 -0
  16. package/config/plan-templates/team-feature.yaml +71 -0
  17. package/config/roles/qa-engineer.yaml +36 -0
  18. package/config/roles/solidity-dev.yaml +51 -0
  19. package/config/roles/tech-architect.yaml +36 -0
  20. package/config/rules/framework/solidity.md +22 -0
  21. package/config/rules/framework/typescript.md +21 -0
  22. package/config/rules/framework/unity.md +21 -0
  23. package/config/rules/universal/design-docs.md +18 -0
  24. package/config/rules/universal/git-hygiene.md +18 -0
  25. package/config/rules/universal/security.md +19 -0
  26. package/config/rules/universal/test-standards.md +19 -0
  27. package/identity/DELEGATION.md +6 -6
  28. package/install.sh +296 -10
  29. package/lib/agent-activity.js +2 -2
  30. package/lib/circling-parser.js +119 -0
  31. package/lib/exec-safety.js +105 -0
  32. package/lib/hyperagent-store.mjs +652 -0
  33. package/lib/kanban-io.js +24 -31
  34. package/lib/llm-providers.js +16 -0
  35. package/lib/mcp-knowledge/bench.mjs +118 -0
  36. package/lib/mcp-knowledge/core.mjs +530 -0
  37. package/lib/mcp-knowledge/package.json +25 -0
  38. package/lib/mcp-knowledge/server.mjs +252 -0
  39. package/lib/mcp-knowledge/test.mjs +802 -0
  40. package/lib/memory-budget.mjs +261 -0
  41. package/lib/mesh-collab.js +483 -165
  42. package/lib/mesh-harness.js +427 -0
  43. package/lib/mesh-plans.js +79 -50
  44. package/lib/mesh-tasks.js +132 -49
  45. package/lib/nats-resolve.js +4 -4
  46. package/lib/plan-templates.js +226 -0
  47. package/lib/pre-compression-flush.mjs +322 -0
  48. package/lib/role-loader.js +292 -0
  49. package/lib/rule-loader.js +358 -0
  50. package/lib/session-store.mjs +461 -0
  51. package/lib/transcript-parser.mjs +292 -0
  52. package/mission-control/drizzle/soul_schema_update.sql +29 -0
  53. package/mission-control/drizzle.config.ts +1 -4
  54. package/mission-control/package-lock.json +1571 -83
  55. package/mission-control/package.json +6 -2
  56. package/mission-control/scripts/gen-chronology.js +3 -3
  57. package/mission-control/scripts/import-pipeline-v2.js +0 -16
  58. package/mission-control/scripts/import-pipeline.js +0 -15
  59. package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
  60. package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
  61. package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
  62. package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
  63. package/mission-control/src/app/api/cowork/events/route.ts +65 -0
  64. package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
  65. package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
  66. package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
  67. package/mission-control/src/app/api/diagnostics/route.ts +97 -0
  68. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
  69. package/mission-control/src/app/api/memory/search/route.ts +6 -3
  70. package/mission-control/src/app/api/mesh/events/route.ts +95 -19
  71. package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
  72. package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
  73. package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
  74. package/mission-control/src/app/api/souls/[id]/evolution/route.ts +21 -5
  75. package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
  76. package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
  77. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +8 -2
  78. package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
  79. package/mission-control/src/app/api/tasks/route.ts +21 -30
  80. package/mission-control/src/app/api/workspace/read/route.ts +11 -0
  81. package/mission-control/src/app/cowork/page.tsx +261 -0
  82. package/mission-control/src/app/diagnostics/page.tsx +385 -0
  83. package/mission-control/src/app/graph/page.tsx +26 -0
  84. package/mission-control/src/app/memory/page.tsx +1 -1
  85. package/mission-control/src/app/obsidian/page.tsx +36 -6
  86. package/mission-control/src/app/roadmap/page.tsx +24 -0
  87. package/mission-control/src/app/souls/page.tsx +2 -2
  88. package/mission-control/src/components/board/execution-config.tsx +431 -0
  89. package/mission-control/src/components/board/kanban-board.tsx +75 -9
  90. package/mission-control/src/components/board/kanban-column.tsx +135 -19
  91. package/mission-control/src/components/board/task-card.tsx +55 -2
  92. package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
  93. package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
  94. package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
  95. package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
  96. package/mission-control/src/components/cowork/role-picker.tsx +102 -0
  97. package/mission-control/src/components/cowork/session-card.tsx +284 -0
  98. package/mission-control/src/components/layout/sidebar.tsx +39 -2
  99. package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
  100. package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
  101. package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
  102. package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
  103. package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
  104. package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
  105. package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
  106. package/mission-control/src/lib/config.ts +67 -0
  107. package/mission-control/src/lib/db/index.ts +85 -1
  108. package/mission-control/src/lib/db/schema.ts +61 -3
  109. package/mission-control/src/lib/hooks.ts +309 -0
  110. package/mission-control/src/lib/memory/entities.ts +3 -2
  111. package/mission-control/src/lib/memory/extract.ts +2 -1
  112. package/mission-control/src/lib/memory/retrieval.ts +3 -2
  113. package/mission-control/src/lib/nats.ts +66 -1
  114. package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
  115. package/mission-control/src/lib/parsers/transcript.ts +4 -4
  116. package/mission-control/src/lib/scheduler.ts +12 -11
  117. package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
  118. package/mission-control/src/lib/sync/tasks.ts +23 -1
  119. package/mission-control/src/lib/task-id.ts +32 -0
  120. package/mission-control/src/lib/tts/index.ts +33 -9
  121. package/mission-control/src/middleware.ts +82 -0
  122. package/mission-control/tsconfig.json +2 -1
  123. package/mission-control/vitest.config.ts +14 -0
  124. package/package.json +15 -2
  125. package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
  126. package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
  127. package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
  128. package/services/launchd/ai.openclaw.mission-control.plist +1 -1
  129. package/services/service-manifest.json +1 -1
  130. package/skills/cc-godmode/references/agents.md +8 -8
  131. package/uninstall.sh +37 -9
  132. package/workspace-bin/memory-daemon.mjs +199 -5
  133. package/workspace-bin/session-search.mjs +204 -0
  134. 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.status = TASK_STATUS.CLAIMED;
193
- task.owner = nodeId;
194
- task.claimed_at = new Date().toISOString();
195
- const budgetMs = (task.budget_minutes || 30) * 60 * 1000;
196
- task.budget_deadline = new Date(Date.now() + budgetMs).toISOString();
197
-
198
- await this.put(task);
199
- return task;
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
- const task = await this.get(taskId);
207
- if (!task) return null;
208
- task.status = TASK_STATUS.RUNNING;
209
- task.started_at = new Date().toISOString();
210
- await this.put(task);
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
- const task = await this.get(taskId);
219
- if (!task) return null;
220
- task.status = TASK_STATUS.COMPLETED;
221
- task.completed_at = new Date().toISOString();
222
- task.result = result;
223
- await this.put(task);
224
- return task;
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
- const task = await this.get(taskId);
232
- if (!task) return null;
233
- task.status = TASK_STATUS.FAILED;
234
- task.completed_at = new Date().toISOString();
235
- task.result = { success: false, summary: reason };
236
- task.attempts = attempts;
237
- await this.put(task);
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
- const task = await this.get(taskId);
246
- if (!task) return null;
247
- task.attempts.push({
248
- ...attempt,
249
- timestamp: new Date().toISOString(),
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
- const task = await this.get(taskId);
261
- if (!task) return null;
262
- task.status = TASK_STATUS.RELEASED;
263
- task.completed_at = new Date().toISOString();
264
- task.result = { success: false, summary: reason, released: true };
265
- if (attempts.length > 0) task.attempts = attempts;
266
- await this.put(task);
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
- const task = await this.get(taskId);
275
- if (!task) return null;
276
- task.last_activity = new Date().toISOString();
277
- await this.put(task);
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
  /**
@@ -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
+ };