openclaw-node-harness 2.0.0 → 2.0.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.
@@ -0,0 +1,528 @@
1
+ /**
2
+ * mesh-plans.js — Plan decomposition and delegation routing for mesh tasks.
3
+ *
4
+ * A plan decomposes a parent task into subtasks with delegation routing
5
+ * (solo_mesh, collab_mesh, local, soul, human), dependency waves, and
6
+ * execution contracts. Plans are stored in MESH_PLANS JetStream KV bucket.
7
+ *
8
+ * Lifecycle: draft → review → approved → executing → completed | aborted
9
+ */
10
+
11
+ const { StringCodec } = require('nats');
12
+ const sc = StringCodec();
13
+
14
+ const PLANS_KV_BUCKET = 'MESH_PLANS';
15
+
16
+ // ── Plan Statuses ──────────────────────────────────
17
+
18
+ const PLAN_STATUS = {
19
+ DRAFT: 'draft',
20
+ REVIEW: 'review',
21
+ APPROVED: 'approved',
22
+ EXECUTING: 'executing',
23
+ COMPLETED: 'completed',
24
+ ABORTED: 'aborted',
25
+ };
26
+
27
+ // ── Subtask Statuses ───────────────────────────────
28
+
29
+ const SUBTASK_STATUS = {
30
+ PENDING: 'pending', // not yet dispatched (waiting for wave)
31
+ QUEUED: 'queued', // dispatched to queue
32
+ RUNNING: 'running', // actively being worked
33
+ COMPLETED: 'completed',
34
+ FAILED: 'failed',
35
+ BLOCKED: 'blocked',
36
+ };
37
+
38
+ // ── Delegation Modes ───────────────────────────────
39
+
40
+ const DELEGATION_MODE = {
41
+ SOLO_MESH: 'solo_mesh',
42
+ COLLAB_MESH: 'collab_mesh',
43
+ LOCAL: 'local',
44
+ SOUL: 'soul',
45
+ HUMAN: 'human',
46
+ };
47
+
48
+ // ── High-Criticality Detection ─────────────────────
49
+
50
+ const HIGH_CRIT_PATHS = ['contracts/', 'auth/', 'payments/', 'migration/'];
51
+ const HIGH_CRIT_KEYWORDS = [
52
+ 'security', 'audit', 'authentication', 'payment',
53
+ 'migration', 'selfdestruct', 'upgrade', 'proxy',
54
+ ];
55
+
56
+ // ── Soul Routing Guide (from DELEGATION.md) ────────
57
+
58
+ const SOUL_ROUTING = {
59
+ 'smart-contract': 'blockchain-auditor',
60
+ 'narrative': 'lore-writer',
61
+ 'lore': 'lore-writer',
62
+ 'cicd': 'infra-ops',
63
+ 'deployment': 'infra-ops',
64
+ 'monitoring': 'infra-ops',
65
+ 'identity': 'identity-architect',
66
+ 'sbt': 'identity-architect',
67
+ 'trust': 'identity-architect',
68
+ };
69
+
70
+ // ── Plan Factory ───────────────────────────────────
71
+
72
+ /**
73
+ * Create a new plan from a parent task.
74
+ *
75
+ * @param {string} parentTaskId — the task being decomposed
76
+ * @param {object} opts — plan metadata
77
+ * @param {Array} subtaskSpecs — array of subtask definitions
78
+ */
79
+ function createPlan({
80
+ parent_task_id,
81
+ title,
82
+ description = '',
83
+ planner = 'daedalus',
84
+ planner_soul = null,
85
+ requires_approval = true,
86
+ subtasks = [],
87
+ }) {
88
+ const planId = `PLAN-${parent_task_id}-${Date.now()}`;
89
+
90
+ // Compute waves from dependency graph
91
+ const enriched = subtasks.map((st, idx) => {
92
+ const subtaskId = st.subtask_id || `${planId}-S${String(idx + 1).padStart(2, '0')}`;
93
+ return {
94
+ subtask_id: subtaskId,
95
+ title: st.title || '',
96
+ description: st.description || '',
97
+
98
+ delegation: st.delegation || {
99
+ mode: DELEGATION_MODE.LOCAL,
100
+ soul_id: null,
101
+ collaboration: null,
102
+ reason: 'default fallback',
103
+ },
104
+
105
+ budget_minutes: parseInt(st.budget_minutes) || 15,
106
+ // Track who set the budget estimate and how (for audit/debugging routing decisions)
107
+ budget_source: st.budget_source || 'planner_estimate', // 'planner_estimate' | 'user_override' | 'metric_based'
108
+ metric: st.metric || null,
109
+ scope: st.scope || [],
110
+ success_criteria: st.success_criteria || [],
111
+
112
+ depends_on: st.depends_on || [],
113
+ wave: 0, // computed below
114
+
115
+ status: SUBTASK_STATUS.PENDING,
116
+ mesh_task_id: null,
117
+ kanban_task_id: null,
118
+ owner: null,
119
+ result: null,
120
+ };
121
+ });
122
+
123
+ // Compute wave assignments
124
+ assignWaves(enriched);
125
+
126
+ const totalBudget = enriched.reduce((sum, st) => sum + st.budget_minutes, 0);
127
+ const maxWave = enriched.reduce((max, st) => Math.max(max, st.wave), 0);
128
+
129
+ return {
130
+ plan_id: planId,
131
+ parent_task_id,
132
+ title,
133
+ description,
134
+
135
+ status: PLAN_STATUS.DRAFT,
136
+
137
+ planner,
138
+ planner_soul,
139
+
140
+ subtasks: enriched,
141
+
142
+ total_budget_minutes: totalBudget,
143
+ estimated_waves: maxWave + 1,
144
+
145
+ requires_approval,
146
+ approved_by: null,
147
+ approved_at: null,
148
+
149
+ created_at: new Date().toISOString(),
150
+ started_at: null,
151
+ completed_at: null,
152
+ };
153
+ }
154
+
155
+ // ── Wave Computation ───────────────────────────────
156
+
157
+ /**
158
+ * Assign wave numbers to subtasks based on dependency DAG.
159
+ * Mutates subtasks in place. Same algorithm as MC's computeWaves.
160
+ */
161
+ function assignWaves(subtasks) {
162
+ const idMap = new Map(subtasks.map(st => [st.subtask_id, st]));
163
+
164
+ // BFS topological layers
165
+ const inDegree = new Map();
166
+ const successors = new Map();
167
+
168
+ for (const st of subtasks) {
169
+ inDegree.set(st.subtask_id, 0);
170
+ successors.set(st.subtask_id, []);
171
+ }
172
+
173
+ for (const st of subtasks) {
174
+ for (const depId of st.depends_on) {
175
+ if (idMap.has(depId)) {
176
+ inDegree.set(st.subtask_id, (inDegree.get(st.subtask_id) || 0) + 1);
177
+ const succs = successors.get(depId) || [];
178
+ succs.push(st.subtask_id);
179
+ successors.set(depId, succs);
180
+ }
181
+ }
182
+ }
183
+
184
+ let wave = 0;
185
+ let currentWave = [];
186
+
187
+ for (const [id, deg] of inDegree) {
188
+ if (deg === 0) currentWave.push(id);
189
+ }
190
+
191
+ while (currentWave.length > 0) {
192
+ for (const id of currentWave) {
193
+ idMap.get(id).wave = wave;
194
+ }
195
+
196
+ const nextWave = [];
197
+ for (const id of currentWave) {
198
+ for (const succ of successors.get(id) || []) {
199
+ const newDeg = (inDegree.get(succ) || 1) - 1;
200
+ inDegree.set(succ, newDeg);
201
+ if (newDeg === 0) nextWave.push(succ);
202
+ }
203
+ }
204
+
205
+ wave++;
206
+ currentWave = nextWave;
207
+ }
208
+ }
209
+
210
+ // ── Delegation Decision Tree ───────────────────────
211
+
212
+ /**
213
+ * Route a subtask to the appropriate delegation mode.
214
+ * Returns a delegation object: { mode, soul_id, collaboration, reason }.
215
+ */
216
+ function routeDelegation(subtask) {
217
+ const title = (subtask.title || '').toLowerCase();
218
+ const desc = (subtask.description || '').toLowerCase();
219
+ const scope = subtask.scope || [];
220
+ const combined = `${title} ${desc}`;
221
+
222
+ // 1. Is it trivial? (< 2 min, indicated by budget)
223
+ // NOTE: budget_minutes is typically a planner estimate (Claude's guess during decomposition).
224
+ // This gate is only as reliable as the estimate. budget_source tracks provenance.
225
+ if (subtask.budget_minutes && subtask.budget_minutes <= 2) {
226
+ return {
227
+ mode: DELEGATION_MODE.LOCAL,
228
+ soul_id: null,
229
+ collaboration: null,
230
+ reason: `Trivial task (budget=${subtask.budget_minutes}min, source=${subtask.budget_source || 'planner_estimate'}), Daedalus inline`,
231
+ };
232
+ }
233
+
234
+ // 2. Does it require human judgment?
235
+ if (combined.includes('approve') || combined.includes('decision') ||
236
+ combined.includes('choose between') || combined.includes('user input')) {
237
+ return {
238
+ mode: DELEGATION_MODE.HUMAN,
239
+ soul_id: null,
240
+ collaboration: null,
241
+ reason: 'Requires human judgment or approval',
242
+ };
243
+ }
244
+
245
+ // 3. Does it match a specialist soul domain?
246
+ for (const [keyword, soulId] of Object.entries(SOUL_ROUTING)) {
247
+ if (combined.includes(keyword)) {
248
+ return {
249
+ mode: DELEGATION_MODE.SOUL,
250
+ soul_id: soulId,
251
+ collaboration: null,
252
+ reason: `Domain match: "${keyword}" → ${soulId}`,
253
+ };
254
+ }
255
+ }
256
+
257
+ // 4. Is it high-criticality? → collab_mesh with review mode
258
+ const isHighCrit = HIGH_CRIT_PATHS.some(p => scope.some(s => s.includes(p)))
259
+ || HIGH_CRIT_KEYWORDS.some(kw => combined.includes(kw));
260
+
261
+ if (isHighCrit) {
262
+ return {
263
+ mode: DELEGATION_MODE.COLLAB_MESH,
264
+ soul_id: null,
265
+ collaboration: {
266
+ mode: 'review',
267
+ min_nodes: 2,
268
+ max_nodes: 3,
269
+ join_window_s: 30,
270
+ max_rounds: 3,
271
+ convergence: { type: 'unanimous' },
272
+ scope_strategy: 'leader_only',
273
+ },
274
+ reason: 'High-criticality path detected, N-node review mode',
275
+ };
276
+ }
277
+
278
+ // 5. Does it have broad scope? → collab_mesh parallel
279
+ if (scope.length > 3) {
280
+ return {
281
+ mode: DELEGATION_MODE.COLLAB_MESH,
282
+ soul_id: null,
283
+ collaboration: {
284
+ mode: 'parallel',
285
+ min_nodes: 2,
286
+ max_nodes: null,
287
+ join_window_s: 30,
288
+ max_rounds: 5,
289
+ convergence: { type: 'majority', threshold: 0.66 },
290
+ scope_strategy: 'partitioned',
291
+ },
292
+ reason: 'Broad scope (>3 paths), parallel collab',
293
+ };
294
+ }
295
+
296
+ // 6. Mechanically verifiable? → solo_mesh
297
+ if (subtask.metric) {
298
+ return {
299
+ mode: DELEGATION_MODE.SOLO_MESH,
300
+ soul_id: null,
301
+ collaboration: null,
302
+ reason: 'Has mechanical metric, solo mesh agent',
303
+ };
304
+ }
305
+
306
+ // 7. Default → local (safest fallback)
307
+ return {
308
+ mode: DELEGATION_MODE.LOCAL,
309
+ soul_id: null,
310
+ collaboration: null,
311
+ reason: 'Default fallback, Daedalus local execution',
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Auto-route all subtasks in a plan that don't already have delegation set.
317
+ * Mutates subtasks in place.
318
+ */
319
+ function autoRoutePlan(plan) {
320
+ for (const st of plan.subtasks) {
321
+ if (!st.delegation || !st.delegation.mode || st.delegation.mode === 'auto') {
322
+ st.delegation = routeDelegation(st);
323
+ }
324
+ }
325
+ return plan;
326
+ }
327
+
328
+ // ── PlanStore (KV-backed) ──────────────────────────
329
+
330
+ class PlanStore {
331
+ constructor(kv) {
332
+ this.kv = kv;
333
+ }
334
+
335
+ async put(plan) {
336
+ await this.kv.put(plan.plan_id, sc.encode(JSON.stringify(plan)));
337
+ return plan;
338
+ }
339
+
340
+ async get(planId) {
341
+ const entry = await this.kv.get(planId);
342
+ if (!entry || !entry.value) return null;
343
+ return JSON.parse(sc.decode(entry.value));
344
+ }
345
+
346
+ async delete(planId) {
347
+ await this.kv.delete(planId);
348
+ }
349
+
350
+ async list(filter = {}) {
351
+ const plans = [];
352
+ const allKeys = [];
353
+ const keys = await this.kv.keys();
354
+ for await (const key of keys) {
355
+ allKeys.push(key);
356
+ }
357
+
358
+ for (const key of allKeys) {
359
+ const entry = await this.kv.get(key);
360
+ if (!entry || !entry.value) continue;
361
+ const plan = JSON.parse(sc.decode(entry.value));
362
+
363
+ if (filter.status && plan.status !== filter.status) continue;
364
+ if (filter.parent_task_id && plan.parent_task_id !== filter.parent_task_id) continue;
365
+
366
+ plans.push(plan);
367
+ }
368
+
369
+ plans.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
370
+ return plans;
371
+ }
372
+
373
+ /**
374
+ * Find plan by parent task ID.
375
+ */
376
+ async findByParentTask(parentTaskId) {
377
+ const plans = await this.list({ parent_task_id: parentTaskId });
378
+ return plans[0] || null;
379
+ }
380
+
381
+ // ── Lifecycle ───────────────────────────────────
382
+
383
+ async submitForReview(planId) {
384
+ const plan = await this.get(planId);
385
+ if (!plan) return null;
386
+ plan.status = PLAN_STATUS.REVIEW;
387
+ await this.put(plan);
388
+ return plan;
389
+ }
390
+
391
+ async approve(planId, approvedBy = 'gui') {
392
+ const plan = await this.get(planId);
393
+ if (!plan) return null;
394
+ plan.status = PLAN_STATUS.APPROVED;
395
+ plan.approved_by = approvedBy;
396
+ plan.approved_at = new Date().toISOString();
397
+ await this.put(plan);
398
+ return plan;
399
+ }
400
+
401
+ async startExecuting(planId) {
402
+ const plan = await this.get(planId);
403
+ if (!plan) return null;
404
+ plan.status = PLAN_STATUS.EXECUTING;
405
+ plan.started_at = new Date().toISOString();
406
+ await this.put(plan);
407
+ return plan;
408
+ }
409
+
410
+ async markCompleted(planId) {
411
+ const plan = await this.get(planId);
412
+ if (!plan) return null;
413
+ plan.status = PLAN_STATUS.COMPLETED;
414
+ plan.completed_at = new Date().toISOString();
415
+ await this.put(plan);
416
+ return plan;
417
+ }
418
+
419
+ async markAborted(planId, reason) {
420
+ const plan = await this.get(planId);
421
+ if (!plan) return null;
422
+ plan.status = PLAN_STATUS.ABORTED;
423
+ plan.completed_at = new Date().toISOString();
424
+ // Mark all pending subtasks as blocked
425
+ for (const st of plan.subtasks) {
426
+ if (st.status === SUBTASK_STATUS.PENDING || st.status === SUBTASK_STATUS.QUEUED) {
427
+ st.status = SUBTASK_STATUS.BLOCKED;
428
+ st.result = { success: false, summary: `Plan aborted: ${reason}` };
429
+ }
430
+ }
431
+ await this.put(plan);
432
+ return plan;
433
+ }
434
+
435
+ // ── Subtask Management ──────────────────────────
436
+
437
+ async updateSubtask(planId, subtaskId, updates) {
438
+ const plan = await this.get(planId);
439
+ if (!plan) return null;
440
+
441
+ const st = plan.subtasks.find(s => s.subtask_id === subtaskId);
442
+ if (!st) return null;
443
+
444
+ Object.assign(st, updates);
445
+ await this.put(plan);
446
+ return plan;
447
+ }
448
+
449
+ /**
450
+ * Get subtasks ready for the next wave.
451
+ * Returns subtasks whose dependencies are all completed and status is pending.
452
+ */
453
+ getNextWaveSubtasks(plan) {
454
+ if (plan.status !== PLAN_STATUS.EXECUTING) return [];
455
+
456
+ const completedIds = new Set(
457
+ plan.subtasks
458
+ .filter(st => st.status === SUBTASK_STATUS.COMPLETED)
459
+ .map(st => st.subtask_id)
460
+ );
461
+
462
+ return plan.subtasks.filter(st => {
463
+ if (st.status !== SUBTASK_STATUS.PENDING) return false;
464
+ return st.depends_on.every(depId => completedIds.has(depId));
465
+ });
466
+ }
467
+
468
+ /**
469
+ * Check if a plan is fully completed (all subtasks done or failed).
470
+ */
471
+ isPlanComplete(plan) {
472
+ return plan.subtasks.every(
473
+ st => st.status === SUBTASK_STATUS.COMPLETED ||
474
+ st.status === SUBTASK_STATUS.FAILED ||
475
+ st.status === SUBTASK_STATUS.BLOCKED
476
+ );
477
+ }
478
+
479
+ /**
480
+ * Check if a plan has any failed subtasks (needs attention).
481
+ */
482
+ hasFailures(plan) {
483
+ return plan.subtasks.some(st => st.status === SUBTASK_STATUS.FAILED);
484
+ }
485
+
486
+ /**
487
+ * Get plan summary for reporting.
488
+ */
489
+ getSummary(plan) {
490
+ const byStatus = {};
491
+ for (const st of plan.subtasks) {
492
+ byStatus[st.status] = (byStatus[st.status] || 0) + 1;
493
+ }
494
+
495
+ const byMode = {};
496
+ for (const st of plan.subtasks) {
497
+ const mode = st.delegation?.mode || 'unknown';
498
+ byMode[mode] = (byMode[mode] || 0) + 1;
499
+ }
500
+
501
+ return {
502
+ plan_id: plan.plan_id,
503
+ parent_task_id: plan.parent_task_id,
504
+ title: plan.title,
505
+ status: plan.status,
506
+ total_subtasks: plan.subtasks.length,
507
+ subtask_status: byStatus,
508
+ delegation_modes: byMode,
509
+ estimated_waves: plan.estimated_waves,
510
+ total_budget_minutes: plan.total_budget_minutes,
511
+ duration_ms: plan.completed_at
512
+ ? new Date(plan.completed_at) - new Date(plan.created_at)
513
+ : Date.now() - new Date(plan.created_at),
514
+ };
515
+ }
516
+ }
517
+
518
+ module.exports = {
519
+ createPlan,
520
+ assignWaves,
521
+ routeDelegation,
522
+ autoRoutePlan,
523
+ PlanStore,
524
+ PLAN_STATUS,
525
+ SUBTASK_STATUS,
526
+ DELEGATION_MODE,
527
+ PLANS_KV_BUCKET,
528
+ };
package/lib/mesh-tasks.js CHANGED
@@ -48,6 +48,11 @@ function createTask({
48
48
  priority = 0,
49
49
  depends_on = [],
50
50
  tags = [],
51
+ collaboration = null,
52
+ preferred_nodes = [],
53
+ exclude_nodes = [],
54
+ llm_provider = null,
55
+ llm_model = null,
51
56
  }) {
52
57
  return {
53
58
  task_id,
@@ -66,9 +71,22 @@ function createTask({
66
71
  depends_on,
67
72
  tags,
68
73
 
74
+ // Node routing
75
+ preferred_nodes, // try these nodes first (ordered by preference)
76
+ exclude_nodes, // never assign to these nodes
77
+
78
+ // LLM selection (null = use agent default)
79
+ llm_provider, // 'claude' | 'openai' | 'shell' | custom
80
+ llm_model, // model override (e.g. 'gpt-4.1', 'opus', 'sonnet')
81
+
82
+ // N-node collaboration (null = solo task, backward compatible)
83
+ // When set, mesh-task-daemon creates a collab session and N nodes
84
+ // coordinate via rounds/reflections instead of single-agent execution.
85
+ collaboration, // { mode, min_nodes, max_nodes, join_window_s, max_rounds, convergence, scope_strategy }
86
+
69
87
  // State (managed by daemon)
70
88
  status: TASK_STATUS.QUEUED,
71
- owner: null, // node_id that claimed it
89
+ owner: null, // node_id that claimed it (solo) or first claimer (collab)
72
90
  created_at: new Date().toISOString(),
73
91
  claimed_at: null,
74
92
  started_at: null,
@@ -94,14 +112,10 @@ class TaskStore {
94
112
  return task;
95
113
  }
96
114
 
97
- async get(taskId, { withRevision = false } = {}) {
115
+ async get(taskId) {
98
116
  const entry = await this.kv.get(taskId);
99
117
  if (!entry || !entry.value) return null;
100
- const task = JSON.parse(sc.decode(entry.value));
101
- if (withRevision) {
102
- return { task, revision: entry.revision };
103
- }
104
- return task;
118
+ return JSON.parse(sc.decode(entry.value));
105
119
  }
106
120
 
107
121
  async delete(taskId) {
@@ -142,46 +156,48 @@ class TaskStore {
142
156
 
143
157
  /**
144
158
  * Claim the highest-priority available task for a node.
145
- * Uses NATS KV revision-based CAS to prevent race conditions.
159
+ * Respects exclude_nodes (hard block) and preferred_nodes (soft priority).
146
160
  * Returns the claimed task, or null if nothing available.
147
161
  */
148
162
  async claim(nodeId) {
149
163
  const available = await this.list({ status: TASK_STATUS.QUEUED });
150
164
 
151
- // Respect dependencies: only claim tasks whose deps are all completed
165
+ // Filter and sort: excluded tasks removed, preferred tasks first
166
+ const claimable = [];
152
167
  for (const task of available) {
168
+ // Hard exclusion
169
+ if (task.exclude_nodes && task.exclude_nodes.includes(nodeId)) continue;
170
+
171
+ // Respect dependencies
153
172
  if (task.depends_on.length > 0) {
154
173
  const depsReady = await this._checkDeps(task.depends_on);
155
174
  if (!depsReady) continue;
156
175
  }
157
176
 
158
- // Re-read with revision for CAS
159
- const result = await this.get(task.task_id, { withRevision: true });
160
- if (!result || result.task.status !== TASK_STATUS.QUEUED) continue;
161
-
162
- // Claim it
163
- result.task.status = TASK_STATUS.CLAIMED;
164
- result.task.owner = nodeId;
165
- result.task.claimed_at = new Date().toISOString();
166
- result.task.budget_deadline = new Date(
167
- Date.now() + result.task.budget_minutes * 60 * 1000
168
- ).toISOString();
169
-
170
- // Atomic CAS update — fails if another agent claimed it first
171
- try {
172
- await this.kv.update(
173
- task.task_id,
174
- sc.encode(JSON.stringify(result.task)),
175
- result.revision
176
- );
177
- return result.task;
178
- } catch (err) {
179
- // Revision mismatch — another agent got there first, try next task
180
- continue;
181
- }
177
+ claimable.push(task);
182
178
  }
183
179
 
184
- return null;
180
+ // Sort: preferred tasks for this node first, then by existing priority/date order
181
+ claimable.sort((a, b) => {
182
+ const aPreferred = a.preferred_nodes && a.preferred_nodes.includes(nodeId) ? 1 : 0;
183
+ const bPreferred = b.preferred_nodes && b.preferred_nodes.includes(nodeId) ? 1 : 0;
184
+ if (bPreferred !== aPreferred) return bPreferred - aPreferred;
185
+ // Fall back to existing sort (priority desc, then created_at asc — already applied by list())
186
+ return 0;
187
+ });
188
+
189
+ if (claimable.length === 0) return null;
190
+
191
+ const task = claimable[0];
192
+ task.status = TASK_STATUS.CLAIMED;
193
+ task.owner = nodeId;
194
+ task.claimed_at = new Date().toISOString();
195
+ task.budget_deadline = new Date(
196
+ Date.now() + task.budget_minutes * 60 * 1000
197
+ ).toISOString();
198
+
199
+ await this.put(task);
200
+ return task;
185
201
  }
186
202
 
187
203
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-node-harness",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "One-command installer for the OpenClaw node layer — identity, skills, souls, daemon, and Mission Control.",
5
5
  "bin": {
6
6
  "openclaw-node": "./cli.js"