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
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect } from "react";
4
+ import { Crown, Wrench, Eye, Shield, User } from "lucide-react";
5
+
6
+ export const ROLES = [
7
+ { value: "lead", label: "Lead", icon: Crown, color: "text-purple-400", bg: "bg-purple-400/10" },
8
+ { value: "implementer", label: "Implementer", icon: Wrench, color: "text-cyan-400", bg: "bg-cyan-400/10" },
9
+ { value: "reviewer", label: "Reviewer", icon: Eye, color: "text-amber-400", bg: "bg-amber-400/10" },
10
+ { value: "auditor", label: "Auditor", icon: Shield, color: "text-red-400", bg: "bg-red-400/10" },
11
+ { value: "worker", label: "Worker", icon: User, color: "text-zinc-400", bg: "bg-zinc-400/10" },
12
+ ] as const;
13
+
14
+ const ROLE_MAP = new Map(ROLES.map((r) => [r.value as string, r]));
15
+
16
+ export function RoleBadge({ role }: { role: string }) {
17
+ const def = ROLE_MAP.get(role);
18
+ if (!def) {
19
+ return (
20
+ <span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium bg-zinc-800 text-zinc-300">
21
+ {role}
22
+ </span>
23
+ );
24
+ }
25
+ const Icon = def.icon;
26
+ return (
27
+ <span
28
+ className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium ${def.bg} ${def.color}`}
29
+ >
30
+ <Icon className="h-3 w-3" />
31
+ {def.label}
32
+ </span>
33
+ );
34
+ }
35
+
36
+ interface RolePickerProps {
37
+ value: string;
38
+ onChange: (role: string) => void;
39
+ }
40
+
41
+ export function RolePicker({ value, onChange }: RolePickerProps) {
42
+ const [open, setOpen] = useState(false);
43
+ const ref = useRef<HTMLDivElement>(null);
44
+
45
+ useEffect(() => {
46
+ const handler = (e: MouseEvent) => {
47
+ if (ref.current && !ref.current.contains(e.target as Node)) {
48
+ setOpen(false);
49
+ }
50
+ };
51
+ document.addEventListener("mousedown", handler);
52
+ return () => document.removeEventListener("mousedown", handler);
53
+ }, []);
54
+
55
+ return (
56
+ <div ref={ref} className="relative">
57
+ <button
58
+ type="button"
59
+ onClick={() => setOpen(!open)}
60
+ className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors"
61
+ >
62
+ <RoleBadge role={value} />
63
+ </button>
64
+ {open && (
65
+ <div className="absolute z-50 mt-1 w-40 rounded-md border border-border bg-card shadow-lg">
66
+ {ROLES.map((r) => {
67
+ const Icon = r.icon;
68
+ return (
69
+ <button
70
+ key={r.value}
71
+ type="button"
72
+ onClick={() => {
73
+ onChange(r.value);
74
+ setOpen(false);
75
+ }}
76
+ className={`flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors hover:bg-accent/50 ${
77
+ value === r.value ? "bg-accent/30" : ""
78
+ }`}
79
+ >
80
+ <Icon className={`h-3.5 w-3.5 ${r.color}`} />
81
+ <span>{r.label}</span>
82
+ </button>
83
+ );
84
+ })}
85
+ <div className="border-t border-border px-3 py-2">
86
+ <input
87
+ type="text"
88
+ placeholder="Custom role..."
89
+ className="w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
90
+ onKeyDown={(e) => {
91
+ if (e.key === "Enter" && e.currentTarget.value.trim()) {
92
+ onChange(e.currentTarget.value.trim());
93
+ setOpen(false);
94
+ }
95
+ }}
96
+ />
97
+ </div>
98
+ </div>
99
+ )}
100
+ </div>
101
+ );
102
+ }
@@ -0,0 +1,284 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import {
5
+ ChevronDown,
6
+ ChevronRight,
7
+ Zap,
8
+ XCircle,
9
+ UserMinus,
10
+ } from "lucide-react";
11
+ import type { CollabSession, Task } from "@/lib/hooks";
12
+ import { interveneSession } from "@/lib/hooks";
13
+ import { RoleBadge } from "./role-picker";
14
+
15
+ const STATUS_COLORS: Record<string, string> = {
16
+ recruiting: "bg-blue-400",
17
+ active: "bg-green-400 animate-pulse",
18
+ converged: "bg-indigo-400",
19
+ completed: "bg-zinc-500",
20
+ aborted: "bg-red-500",
21
+ };
22
+
23
+ const MODE_BADGE: Record<string, string> = {
24
+ parallel: "bg-cyan-400/10 text-cyan-400",
25
+ sequential: "bg-amber-400/10 text-amber-400",
26
+ review: "bg-purple-400/10 text-purple-400",
27
+ };
28
+
29
+ function NodeChip({
30
+ node,
31
+ sessionId,
32
+ isActive,
33
+ }: {
34
+ node: CollabSession["nodes"][0];
35
+ sessionId: string;
36
+ isActive: boolean;
37
+ }) {
38
+ const statusDot: Record<string, string> = {
39
+ recruited: "bg-blue-400",
40
+ active: "bg-green-400 animate-pulse",
41
+ idle: "bg-zinc-500",
42
+ dead: "bg-red-500",
43
+ };
44
+
45
+ const handleRemove = async () => {
46
+ if (!confirm(`Remove ${node.node_id} from session?`)) return;
47
+ await interveneSession({
48
+ action: "remove_node",
49
+ sessionId,
50
+ nodeId: node.node_id,
51
+ });
52
+ };
53
+
54
+ return (
55
+ <span className="group inline-flex items-center gap-1.5 rounded-md bg-card border border-border px-2 py-1 text-xs">
56
+ <span
57
+ className={`h-1.5 w-1.5 rounded-full ${statusDot[node.status] ?? "bg-zinc-600"}`}
58
+ />
59
+ <span className="font-mono text-[11px]">
60
+ {node.node_id.split("-")[0]}
61
+ </span>
62
+ <RoleBadge role={node.role} />
63
+ {isActive && node.status !== "dead" && (
64
+ <button
65
+ onClick={handleRemove}
66
+ className="hidden group-hover:inline-flex text-muted-foreground hover:text-red-400"
67
+ title="Remove node"
68
+ >
69
+ <UserMinus className="h-3 w-3" />
70
+ </button>
71
+ )}
72
+ </span>
73
+ );
74
+ }
75
+
76
+ const KANBAN_STATUS_CHIP: Record<string, string> = {
77
+ backlog: "bg-blue-500/15 text-blue-400",
78
+ in_progress: "bg-green-500/15 text-green-400",
79
+ review: "bg-yellow-500/15 text-yellow-400",
80
+ done: "bg-zinc-500/15 text-zinc-400",
81
+ };
82
+
83
+ export function SessionCard({ session, linkedTask }: { session: CollabSession; linkedTask?: Task | null }) {
84
+ const [expanded, setExpanded] = useState(false);
85
+ const isActive = ["recruiting", "active"].includes(session.status);
86
+
87
+ const currentRound = session.rounds?.[session.rounds.length - 1];
88
+ const reflectionCount = currentRound?.reflections?.length ?? 0;
89
+ const activeNodes = (session.nodes || []).filter(
90
+ (n) => n.status !== "dead"
91
+ ).length;
92
+
93
+ const handleAbort = async () => {
94
+ if (!confirm("Abort this session? The parent task will be cancelled."))
95
+ return;
96
+ await interveneSession({ action: "abort", sessionId: session.session_id });
97
+ };
98
+
99
+ const handleForceConverge = async () => {
100
+ if (
101
+ !confirm(
102
+ "Force convergence? Missing reflections will be filled synthetically."
103
+ )
104
+ )
105
+ return;
106
+ await interveneSession({
107
+ action: "force_converge",
108
+ sessionId: session.session_id,
109
+ });
110
+ };
111
+
112
+ return (
113
+ <div className="rounded-lg border border-border bg-card">
114
+ {/* Header */}
115
+ <div
116
+ className="flex items-center justify-between px-4 py-3 cursor-pointer"
117
+ onClick={() => setExpanded(!expanded)}
118
+ >
119
+ <div className="flex items-center gap-3">
120
+ {expanded ? (
121
+ <ChevronDown className="h-4 w-4 text-muted-foreground" />
122
+ ) : (
123
+ <ChevronRight className="h-4 w-4 text-muted-foreground" />
124
+ )}
125
+ <span
126
+ className={`h-2 w-2 rounded-full ${STATUS_COLORS[session.status] ?? "bg-zinc-600"}`}
127
+ />
128
+ <a
129
+ href={`/?task=${session.task_id}`}
130
+ onClick={(e) => e.stopPropagation()}
131
+ className="font-mono text-sm hover:text-cyan-400 underline underline-offset-2 decoration-dotted"
132
+ >
133
+ {session.task_id}
134
+ </a>
135
+ <span
136
+ className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${MODE_BADGE[session.mode] ?? "bg-zinc-700 text-zinc-300"}`}
137
+ >
138
+ {session.mode}
139
+ </span>
140
+ <span className="text-[10px] text-muted-foreground uppercase">
141
+ {session.status}
142
+ </span>
143
+ {linkedTask && (
144
+ <span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${KANBAN_STATUS_CHIP[linkedTask.kanbanColumn] ?? "bg-zinc-700 text-zinc-300"}`}>
145
+ {linkedTask.kanbanColumn === "in_progress" ? "In Progress" : linkedTask.kanbanColumn}
146
+ </span>
147
+ )}
148
+ </div>
149
+ </div>
150
+
151
+ {/* Quick stats */}
152
+ <div className="flex items-center gap-4 px-4 pb-3 text-xs text-muted-foreground">
153
+ <span>
154
+ Round {session.current_round}/{session.max_rounds}
155
+ </span>
156
+ <span>
157
+ Nodes: {activeNodes}/{(session.nodes || []).length}
158
+ </span>
159
+ {currentRound && (
160
+ <span>
161
+ Reflections: {reflectionCount}/{activeNodes}
162
+ </span>
163
+ )}
164
+ <span className="capitalize">{session.convergence?.type}</span>
165
+ </div>
166
+
167
+ {/* Round progress bar */}
168
+ <div className="flex gap-1 px-4 pb-3">
169
+ {Array.from({ length: session.max_rounds }, (_, i) => {
170
+ const roundNum = i + 1;
171
+ const round = session.rounds?.find(
172
+ (r) => r.round_number === roundNum
173
+ );
174
+ let color = "bg-zinc-700";
175
+ if (round?.completed_at) color = "bg-green-500";
176
+ else if (round && !round.completed_at)
177
+ color = "bg-blue-500 animate-pulse";
178
+ return (
179
+ <div
180
+ key={i}
181
+ className={`h-1.5 flex-1 rounded-full ${color}`}
182
+ title={`Round ${roundNum}`}
183
+ />
184
+ );
185
+ })}
186
+ </div>
187
+
188
+ {/* Nodes */}
189
+ <div className="flex flex-wrap gap-1.5 px-4 pb-3">
190
+ {(session.nodes || []).map((node) => (
191
+ <NodeChip
192
+ key={node.node_id}
193
+ node={node}
194
+ sessionId={session.session_id}
195
+ isActive={isActive}
196
+ />
197
+ ))}
198
+ </div>
199
+
200
+ {/* Intervention buttons */}
201
+ {isActive && (
202
+ <div className="flex gap-2 px-4 pb-3">
203
+ <button
204
+ onClick={handleForceConverge}
205
+ className="flex items-center gap-1 rounded px-2 py-1 text-xs bg-purple-500/10 text-purple-400 hover:bg-purple-500/20 transition-colors"
206
+ >
207
+ <Zap className="h-3 w-3" />
208
+ Force Converge
209
+ </button>
210
+ <button
211
+ onClick={handleAbort}
212
+ className="flex items-center gap-1 rounded px-2 py-1 text-xs bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors"
213
+ >
214
+ <XCircle className="h-3 w-3" />
215
+ Abort
216
+ </button>
217
+ </div>
218
+ )}
219
+
220
+ {/* Expanded: round history */}
221
+ {expanded && session.rounds && session.rounds.length > 0 && (
222
+ <div className="border-t border-border px-4 py-3 space-y-3">
223
+ {session.rounds.map((round) => (
224
+ <div key={round.round_number} className="space-y-1">
225
+ <div className="flex items-center gap-2 text-xs">
226
+ <span className="font-medium">R{round.round_number}</span>
227
+ <span className="text-muted-foreground">
228
+ {round.completed_at ? "completed" : "in progress"}
229
+ </span>
230
+ <span className="text-muted-foreground">
231
+ {round.reflections?.length ?? 0} reflections
232
+ </span>
233
+ </div>
234
+ {round.reflections?.map((ref) => (
235
+ <div
236
+ key={`${ref.node_id}-${round.round_number}`}
237
+ className="ml-4 rounded bg-accent/30 px-3 py-2 text-xs space-y-1"
238
+ >
239
+ <div className="flex items-center gap-2">
240
+ <span className="font-mono text-[11px]">
241
+ {ref.node_id.split("-")[0]}
242
+ </span>
243
+ <span
244
+ className={`rounded px-1 py-0.5 text-[9px] ${
245
+ ref.vote === "converged"
246
+ ? "bg-green-500/10 text-green-400"
247
+ : ref.vote === "blocked"
248
+ ? "bg-red-500/10 text-red-400"
249
+ : "bg-zinc-700 text-zinc-300"
250
+ }`}
251
+ >
252
+ {ref.vote}
253
+ </span>
254
+ <span className="text-muted-foreground">
255
+ {Math.round(ref.confidence * 100)}%
256
+ </span>
257
+ {ref.synthetic && (
258
+ <span className="text-[9px] text-yellow-500">
259
+ synthetic
260
+ </span>
261
+ )}
262
+ </div>
263
+ {ref.summary && (
264
+ <p className="text-muted-foreground line-clamp-2">
265
+ {ref.summary}
266
+ </p>
267
+ )}
268
+ </div>
269
+ ))}
270
+ </div>
271
+ ))}
272
+
273
+ {/* Timestamps */}
274
+ <div className="pt-2 border-t border-border/50 text-[10px] text-muted-foreground flex gap-4">
275
+ <span>Started: {session.created_at?.slice(0, 19)}</span>
276
+ {session.completed_at && (
277
+ <span>Completed: {session.completed_at.slice(0, 19)}</span>
278
+ )}
279
+ </div>
280
+ </div>
281
+ )}
282
+ </div>
283
+ );
284
+ }
@@ -2,8 +2,8 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { usePathname } from "next/navigation";
5
- import { LayoutDashboard, Search, RefreshCw, Users, Calendar, GitBranch, BarChart3, MessageCircle, Network, Waypoints, Settings, Server } from "lucide-react";
6
- import { useState } from "react";
5
+ import { LayoutDashboard, Search, RefreshCw, Users, Users2, Calendar, GitBranch, BarChart3, MessageCircle, Network, Waypoints, Settings, Server, Activity } from "lucide-react";
6
+ import { useState, useEffect } from "react";
7
7
  import { LiveStream } from "@/components/board/live-stream";
8
8
 
9
9
  const NAV = [
@@ -17,9 +17,43 @@ const NAV = [
17
17
  { href: "/graph", label: "Knowledge Graph", icon: Network },
18
18
  { href: "/obsidian", label: "Obsidian View", icon: Waypoints },
19
19
  { href: "/mesh", label: "Mesh Nodes", icon: Server },
20
+ { href: "/cowork", label: "Cowork", icon: Users2 },
20
21
  { href: "/settings", label: "Settings", icon: Settings },
22
+ { href: "/diagnostics", label: "Diagnostics", icon: Activity },
21
23
  ];
22
24
 
25
+ function NodeBadge() {
26
+ const [identity, setIdentity] = useState<{
27
+ nodeId: string;
28
+ role: "lead" | "worker";
29
+ platform: string;
30
+ } | null>(null);
31
+
32
+ useEffect(() => {
33
+ fetch("/api/mesh/identity")
34
+ .then((r) => r.json())
35
+ .then(setIdentity)
36
+ .catch(() => setIdentity(null));
37
+ }, []);
38
+
39
+ if (!identity) return null;
40
+
41
+ const isLead = identity.role === "lead";
42
+ return (
43
+ <div
44
+ className={`flex items-center gap-1.5 rounded px-2 py-0.5 text-[10px] font-medium tracking-wider uppercase ${
45
+ isLead
46
+ ? "bg-green-500/10 text-green-400"
47
+ : "bg-blue-500/10 text-blue-400"
48
+ }`}
49
+ title={`${identity.nodeId} (${identity.platform})`}
50
+ >
51
+ <span>{isLead ? "⬢" : "◇"}</span>
52
+ <span>{identity.role}</span>
53
+ </div>
54
+ );
55
+ }
56
+
23
57
  export function Sidebar() {
24
58
  const pathname = usePathname();
25
59
  const [syncing, setSyncing] = useState(false);
@@ -40,6 +74,9 @@ export function Sidebar() {
40
74
  <span className="text-sm font-semibold tracking-tight">
41
75
  Mission Control
42
76
  </span>
77
+ <div className="ml-auto">
78
+ <NodeBadge />
79
+ </div>
43
80
  </div>
44
81
 
45
82
  <nav className="px-2 py-3 space-y-1">
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import { parseDailyLog, listDailyLogs } from "../parsers/daily-log";
6
+
7
+ let tmpDir: string;
8
+
9
+ beforeEach(() => {
10
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "daily-log-test-"));
11
+ });
12
+
13
+ afterEach(() => {
14
+ fs.rmSync(tmpDir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("parseDailyLog", () => {
18
+ it("parses a daily log with H1 heading", () => {
19
+ const filePath = path.join(tmpDir, "2026-03-15.md");
20
+ fs.writeFileSync(filePath, "# Session Notes\n\n- Did stuff\n- More stuff\n");
21
+ const entry = parseDailyLog(filePath);
22
+
23
+ expect(entry.date).toBe("2026-03-15");
24
+ expect(entry.title).toBe("Session Notes");
25
+ expect(entry.content).toContain("- Did stuff");
26
+ expect(entry.filePath).toBe(filePath);
27
+ expect(entry.modifiedAt).toBeDefined();
28
+ });
29
+
30
+ it("extracts date from filename", () => {
31
+ const filePath = path.join(tmpDir, "2026-01-01.md");
32
+ fs.writeFileSync(filePath, "content\n");
33
+ const entry = parseDailyLog(filePath);
34
+ expect(entry.date).toBe("2026-01-01");
35
+ });
36
+
37
+ it("falls back to date when no H1 heading", () => {
38
+ const filePath = path.join(tmpDir, "2026-02-28.md");
39
+ fs.writeFileSync(filePath, "No heading here\n");
40
+ const entry = parseDailyLog(filePath);
41
+ expect(entry.title).toBe("2026-02-28");
42
+ });
43
+
44
+ it("uses basename for non-date filenames", () => {
45
+ const filePath = path.join(tmpDir, "notes.md");
46
+ fs.writeFileSync(filePath, "content\n");
47
+ const entry = parseDailyLog(filePath);
48
+ expect(entry.date).toBe("notes");
49
+ });
50
+
51
+ it("throws for missing file", () => {
52
+ expect(() => parseDailyLog("/nonexistent/file.md")).toThrow("not found");
53
+ });
54
+ });
55
+
56
+ describe("listDailyLogs", () => {
57
+ it("lists YYYY-MM-DD.md files sorted chronologically", () => {
58
+ fs.writeFileSync(path.join(tmpDir, "2026-03-15.md"), "");
59
+ fs.writeFileSync(path.join(tmpDir, "2026-03-10.md"), "");
60
+ fs.writeFileSync(path.join(tmpDir, "2026-03-20.md"), "");
61
+ const logs = listDailyLogs(tmpDir);
62
+ expect(logs).toHaveLength(3);
63
+ expect(logs[0]).toContain("2026-03-10.md");
64
+ expect(logs[2]).toContain("2026-03-20.md");
65
+ });
66
+
67
+ it("excludes non-date files", () => {
68
+ fs.writeFileSync(path.join(tmpDir, "2026-03-15.md"), "");
69
+ fs.writeFileSync(path.join(tmpDir, "notes.md"), "");
70
+ fs.writeFileSync(path.join(tmpDir, "README.md"), "");
71
+ const logs = listDailyLogs(tmpDir);
72
+ expect(logs).toHaveLength(1);
73
+ });
74
+
75
+ it("returns empty for nonexistent directory", () => {
76
+ expect(listDailyLogs("/nonexistent/dir")).toEqual([]);
77
+ });
78
+
79
+ it("returns empty for empty directory", () => {
80
+ expect(listDailyLogs(tmpDir)).toEqual([]);
81
+ });
82
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import { parseMemoryMd } from "../parsers/memory-md";
6
+
7
+ let tmpDir: string;
8
+
9
+ beforeEach(() => {
10
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memory-md-test-"));
11
+ });
12
+
13
+ afterEach(() => {
14
+ fs.rmSync(tmpDir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("parseMemoryMd", () => {
18
+ it("extracts H1 title", () => {
19
+ const filePath = path.join(tmpDir, "MEMORY.md");
20
+ fs.writeFileSync(filePath, "# Long-Term Memory\n\nSome content\n");
21
+ const result = parseMemoryMd(filePath);
22
+ expect(result.title).toBe("Long-Term Memory");
23
+ });
24
+
25
+ it("defaults title to MEMORY.md when no H1", () => {
26
+ const filePath = path.join(tmpDir, "MEMORY.md");
27
+ fs.writeFileSync(filePath, "No heading\n## Section\nContent\n");
28
+ const result = parseMemoryMd(filePath);
29
+ expect(result.title).toBe("MEMORY.md");
30
+ });
31
+
32
+ it("parses ## sections", () => {
33
+ const filePath = path.join(tmpDir, "MEMORY.md");
34
+ fs.writeFileSync(filePath, [
35
+ "# Memory",
36
+ "",
37
+ "## User Context",
38
+ "- Senior dev",
39
+ "- Likes concise code",
40
+ "",
41
+ "## Project Notes",
42
+ "- Building mesh network",
43
+ ].join("\n"));
44
+ const result = parseMemoryMd(filePath);
45
+ expect(result.sections).toHaveLength(2);
46
+ expect(result.sections[0].heading).toBe("User Context");
47
+ expect(result.sections[0].content).toContain("Senior dev");
48
+ expect(result.sections[1].heading).toBe("Project Notes");
49
+ });
50
+
51
+ it("handles empty sections", () => {
52
+ const filePath = path.join(tmpDir, "MEMORY.md");
53
+ fs.writeFileSync(filePath, "# Memory\n## Empty\n## Next\nContent\n");
54
+ const result = parseMemoryMd(filePath);
55
+ expect(result.sections).toHaveLength(2);
56
+ expect(result.sections[0].content).toBe("");
57
+ expect(result.sections[1].content).toBe("Content");
58
+ });
59
+
60
+ it("preserves full content", () => {
61
+ const filePath = path.join(tmpDir, "MEMORY.md");
62
+ const content = "# Memory\n\nRaw content here\n";
63
+ fs.writeFileSync(filePath, content);
64
+ const result = parseMemoryMd(filePath);
65
+ expect(result.fullContent).toBe(content);
66
+ });
67
+
68
+ it("includes modification timestamp", () => {
69
+ const filePath = path.join(tmpDir, "MEMORY.md");
70
+ fs.writeFileSync(filePath, "content");
71
+ const result = parseMemoryMd(filePath);
72
+ expect(result.modifiedAt).toBeDefined();
73
+ expect(new Date(result.modifiedAt).getTime()).toBeGreaterThan(0);
74
+ });
75
+
76
+ it("throws for missing file", () => {
77
+ expect(() => parseMemoryMd("/nonexistent/MEMORY.md")).toThrow("not found");
78
+ });
79
+
80
+ it("handles file with no sections", () => {
81
+ const filePath = path.join(tmpDir, "MEMORY.md");
82
+ fs.writeFileSync(filePath, "# Memory\nJust a title and text\n");
83
+ const result = parseMemoryMd(filePath);
84
+ expect(result.sections).toHaveLength(0);
85
+ expect(result.title).toBe("Memory");
86
+ });
87
+ });