openclaw-node-harness 2.0.4 → 2.1.0
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/mesh-agent.js +401 -12
- package/bin/mesh-bridge.js +66 -1
- package/bin/mesh-task-daemon.js +816 -26
- package/bin/mesh.js +403 -1
- 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 +293 -8
- package/lib/circling-parser.js +119 -0
- package/lib/hyperagent-store.mjs +652 -0
- package/lib/kanban-io.js +9 -0
- package/lib/mcp-knowledge/bench.mjs +118 -0
- package/lib/mcp-knowledge/core.mjs +528 -0
- package/lib/mcp-knowledge/package.json +25 -0
- package/lib/mcp-knowledge/server.mjs +245 -0
- package/lib/mcp-knowledge/test.mjs +802 -0
- package/lib/memory-budget.mjs +261 -0
- package/lib/mesh-collab.js +301 -1
- package/lib/mesh-harness.js +427 -0
- package/lib/mesh-plans.js +13 -5
- package/lib/mesh-tasks.js +67 -0
- package/lib/plan-templates.js +226 -0
- package/lib/pre-compression-flush.mjs +320 -0
- package/lib/role-loader.js +292 -0
- package/lib/rule-loader.js +358 -0
- package/lib/session-store.mjs +458 -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/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/tasks/[id]/handoff/route.ts +1 -1
- 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/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 +58 -0
- package/mission-control/src/lib/db/index.ts +69 -0
- 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/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/tsconfig.json +2 -1
- package/mission-control/vitest.config.ts +14 -0
- package/package.json +15 -2
- package/services/service-manifest.json +1 -1
- package/skills/cc-godmode/references/agents.md +8 -8
- package/workspace-bin/memory-daemon.mjs +199 -5
- package/workspace-bin/session-search.mjs +204 -0
- 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
|
+
});
|